Skip to main content

tork_core/
multipart.rs

1//! Multipart and form request handling: file uploads and form fields.
2//!
3//! A `multipart/form-data` body is parsed once into a [`MultipartForm`]: text
4//! fields are kept in memory, and file fields are spooled into a temporary file
5//! (in memory up to a threshold, then on disk). Handlers consume fields as
6//! [`FileBytes`] (buffered), [`UploadFile`] (spooled), or typed text values.
7
8use std::fs::OpenOptions;
9use std::io::{Read, Seek, SeekFrom, Write};
10use std::path::{Component, Path, PathBuf};
11use std::str::FromStr;
12
13use bytes::Bytes;
14use garde::Validate;
15use http::header::CONTENT_TYPE;
16use http_body_util::BodyDataStream;
17use mime::Mime;
18use serde::de::DeserializeOwned;
19use tempfile::SpooledTempFile;
20
21use crate::error::{Error, Result};
22use crate::extract::{FromRequest, RequestContext};
23
24/// Default cap on the total decoded size of a multipart body.
25const DEFAULT_MAX_BODY_SIZE: usize = 16 * 1024 * 1024;
26/// Default cap on a single uploaded file.
27const DEFAULT_MAX_FILE_SIZE: usize = 8 * 1024 * 1024;
28/// Default cap on a single text field.
29const DEFAULT_MAX_TEXT_FIELD_SIZE: usize = 1024 * 1024;
30/// Default size a file may reach in memory before spilling to disk.
31const DEFAULT_MEMORY_THRESHOLD: usize = 1024 * 1024;
32/// Default cap on the number of file parts in one request.
33const DEFAULT_MAX_FILES: usize = 32;
34/// Default cap on the total number of parts (text + file) in one request.
35///
36/// Bounds the per-request allocation/parse amplification from a flood of tiny
37/// fields, which the byte-size limits alone do not constrain.
38const DEFAULT_MAX_FIELDS: usize = 1000;
39/// How many buffered bytes accumulate before flushing to the spool file. Bounds
40/// in-memory buffering while amortizing the cost of moving writes off-runtime.
41const SPOOL_FLUSH_THRESHOLD: usize = 256 * 1024;
42
43/// Limits and temp-file behavior for multipart uploads.
44///
45/// Configure app-wide defaults with [`App::upload_config`](crate::App::upload_config)
46/// or per route with `#[post("/p", upload(...))]`; a route value overrides the
47/// app default. Unset fields fall back to the framework defaults.
48#[derive(Clone, Default)]
49pub struct UploadConfig {
50    max_body_size: Option<usize>,
51    max_file_size: Option<usize>,
52    max_text_field_size: Option<usize>,
53    memory_threshold: Option<usize>,
54    max_files: Option<usize>,
55    max_fields: Option<usize>,
56    temp_dir: Option<PathBuf>,
57}
58
59impl UploadConfig {
60    /// Creates an empty configuration (all limits at their defaults).
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Sets the maximum total multipart body size, in bytes.
66    pub fn max_body_size(mut self, bytes: usize) -> Self {
67        self.max_body_size = Some(bytes);
68        self
69    }
70
71    /// Sets the maximum total multipart body size, in mebibytes.
72    pub fn max_body_size_mb(self, mb: usize) -> Self {
73        self.max_body_size(mb * 1024 * 1024)
74    }
75
76    /// Sets the maximum size of a single uploaded file, in bytes.
77    pub fn max_file_size(mut self, bytes: usize) -> Self {
78        self.max_file_size = Some(bytes);
79        self
80    }
81
82    /// Sets the maximum size of a single uploaded file, in mebibytes.
83    pub fn max_file_size_mb(self, mb: usize) -> Self {
84        self.max_file_size(mb * 1024 * 1024)
85    }
86
87    /// Sets the maximum size of a single text field, in bytes.
88    pub fn max_text_field_size(mut self, bytes: usize) -> Self {
89        self.max_text_field_size = Some(bytes);
90        self
91    }
92
93    /// Sets the maximum size of a single text field, in kibibytes.
94    pub fn max_text_field_size_kb(self, kb: usize) -> Self {
95        self.max_text_field_size(kb * 1024)
96    }
97
98    /// Sets the in-memory threshold before a file spills to disk, in bytes.
99    pub fn memory_threshold(mut self, bytes: usize) -> Self {
100        self.memory_threshold = Some(bytes);
101        self
102    }
103
104    /// Sets the maximum number of file parts per request.
105    pub fn max_files(mut self, count: usize) -> Self {
106        self.max_files = Some(count);
107        self
108    }
109
110    /// Sets the maximum total number of parts (text + file) per request.
111    ///
112    /// Guards against a flood of tiny fields that the byte-size limits do not
113    /// catch. Defaults to 1000.
114    pub fn max_fields(mut self, count: usize) -> Self {
115        self.max_fields = Some(count);
116        self
117    }
118
119    /// Sets the directory for spilled temporary files.
120    pub fn temp_dir(mut self, dir: impl Into<PathBuf>) -> Self {
121        self.temp_dir = Some(dir.into());
122        self
123    }
124
125    /// Returns a copy with each unset field taken from `base` (route over app).
126    pub(crate) fn merge(self, base: &UploadConfig) -> Self {
127        Self {
128            max_body_size: self.max_body_size.or(base.max_body_size),
129            max_file_size: self.max_file_size.or(base.max_file_size),
130            max_text_field_size: self.max_text_field_size.or(base.max_text_field_size),
131            memory_threshold: self.memory_threshold.or(base.memory_threshold),
132            max_files: self.max_files.or(base.max_files),
133            max_fields: self.max_fields.or(base.max_fields),
134            temp_dir: self.temp_dir.or_else(|| base.temp_dir.clone()),
135        }
136    }
137
138    /// Resolves every field, applying defaults.
139    fn resolve(&self) -> ResolvedConfig {
140        ResolvedConfig {
141            max_body_size: self.max_body_size.unwrap_or(DEFAULT_MAX_BODY_SIZE),
142            max_file_size: self.max_file_size.unwrap_or(DEFAULT_MAX_FILE_SIZE),
143            max_text_field_size: self
144                .max_text_field_size
145                .unwrap_or(DEFAULT_MAX_TEXT_FIELD_SIZE),
146            memory_threshold: self.memory_threshold.unwrap_or(DEFAULT_MEMORY_THRESHOLD),
147            max_files: self.max_files.unwrap_or(DEFAULT_MAX_FILES),
148            max_fields: self.max_fields.unwrap_or(DEFAULT_MAX_FIELDS),
149            temp_dir: self.temp_dir.clone(),
150        }
151    }
152}
153
154/// A fully-resolved upload configuration used by the parser.
155struct ResolvedConfig {
156    max_body_size: usize,
157    max_file_size: usize,
158    max_text_field_size: usize,
159    memory_threshold: usize,
160    max_files: usize,
161    max_fields: usize,
162    temp_dir: Option<PathBuf>,
163}
164
165impl ResolvedConfig {
166    /// Creates a spooled temp file, spilling to the configured directory if set.
167    ///
168    /// Honors `temp_dir` so that, in containers where the default `/tmp` is a
169    /// memory-backed `tmpfs`, large uploads spill to real disk instead of RAM.
170    fn new_spool(&self) -> SpooledTempFile {
171        match &self.temp_dir {
172            Some(dir) => tempfile::spooled_tempfile_in(self.memory_threshold, dir),
173            None => SpooledTempFile::new(self.memory_threshold),
174        }
175    }
176}
177
178/// The application-wide default upload configuration, stored in the state map.
179#[derive(Clone)]
180pub(crate) struct AppUploadConfig(pub(crate) UploadConfig);
181
182/// A buffered uploaded file, held entirely in memory.
183///
184/// Use this for small files. For large files prefer [`UploadFile`], which spools
185/// to disk past a threshold.
186pub struct FileBytes {
187    bytes: Bytes,
188    filename: Option<String>,
189    content_type: Option<Mime>,
190}
191
192impl FileBytes {
193    pub(crate) fn new(bytes: Bytes, filename: Option<String>, content_type: Option<Mime>) -> Self {
194        Self {
195            bytes,
196            filename,
197            content_type,
198        }
199    }
200
201    /// Returns the file size in bytes.
202    pub fn len(&self) -> usize {
203        self.bytes.len()
204    }
205
206    /// Returns `true` if the file is empty.
207    pub fn is_empty(&self) -> bool {
208        self.bytes.is_empty()
209    }
210
211    /// Returns the file contents.
212    pub fn bytes(&self) -> &[u8] {
213        &self.bytes
214    }
215
216    /// Consumes the file, returning its contents.
217    pub fn into_bytes(self) -> Bytes {
218        self.bytes
219    }
220
221    /// Returns the client-provided filename, if any.
222    pub fn filename(&self) -> Option<&str> {
223        self.filename.as_deref()
224    }
225
226    /// Returns the declared content type, if any.
227    pub fn content_type(&self) -> Option<&Mime> {
228        self.content_type.as_ref()
229    }
230}
231
232/// An uploaded file backed by a spooled temporary file.
233///
234/// The contents stay in memory up to a threshold and then spill to disk, so large
235/// uploads do not exhaust memory. Reads and saves run on a blocking thread.
236pub struct UploadFile {
237    filename: Option<String>,
238    content_type: Option<Mime>,
239    size: u64,
240    storage: Option<SpooledTempFile>,
241}
242
243impl UploadFile {
244    pub(crate) fn new(
245        filename: Option<String>,
246        content_type: Option<Mime>,
247        size: u64,
248        storage: SpooledTempFile,
249    ) -> Self {
250        Self {
251            filename,
252            content_type,
253            size,
254            storage: Some(storage),
255        }
256    }
257
258    /// Returns the client-provided filename, if any.
259    pub fn filename(&self) -> Option<&str> {
260        self.filename.as_deref()
261    }
262
263    /// Returns the declared content type, if any.
264    pub fn content_type(&self) -> Option<&Mime> {
265        self.content_type.as_ref()
266    }
267
268    /// Returns the file size in bytes.
269    pub fn size(&self) -> u64 {
270        self.size
271    }
272
273    /// Runs a blocking operation over the spooled storage, restoring it after.
274    async fn with_storage<F, R>(&mut self, op: F) -> Result<R>
275    where
276        F: FnOnce(SpooledTempFile) -> std::io::Result<(SpooledTempFile, R)> + Send + 'static,
277        R: Send + 'static,
278    {
279        let storage = self
280            .storage
281            .take()
282            .ok_or_else(|| Error::internal("upload file storage was already consumed"))?;
283        let (storage, result) = tokio::task::spawn_blocking(move || op(storage))
284            .await
285            .map_err(|error| Error::internal(format!("upload IO task failed: {error}")))?
286            .map_err(|error| Error::internal(format!("upload IO error: {error}")))?;
287        self.storage = Some(storage);
288        Ok(result)
289    }
290
291    /// Reads the whole file into memory.
292    pub async fn read(&mut self) -> Result<Bytes> {
293        self.with_storage(|mut storage| {
294            storage.seek(SeekFrom::Start(0))?;
295            let mut buffer = Vec::new();
296            storage.read_to_end(&mut buffer)?;
297            Ok((storage, Bytes::from(buffer)))
298        })
299        .await
300    }
301
302    /// Reads up to `size` bytes from the current position, or `None` at the end.
303    pub async fn read_chunk(&mut self, size: usize) -> Result<Option<Bytes>> {
304        self.with_storage(move |mut storage| {
305            let mut buffer = vec![0u8; size];
306            let read = storage.read(&mut buffer)?;
307            buffer.truncate(read);
308            let chunk = (read != 0).then(|| Bytes::from(buffer));
309            Ok((storage, chunk))
310        })
311        .await
312    }
313
314    /// Rewinds to the start of the file.
315    pub async fn seek_start(&mut self) -> Result<()> {
316        self.with_storage(|mut storage| {
317            storage.seek(SeekFrom::Start(0))?;
318            Ok((storage, ()))
319        })
320        .await
321    }
322
323    /// Writes the file to `path`.
324    pub async fn save_to<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
325        let path = path.as_ref().to_path_buf();
326        validate_save_path(&path)?;
327        self.write_to_path(path).await
328    }
329
330    /// Writes the file into `dir` using a single safe `file_name`.
331    pub async fn save_to_dir<P: AsRef<Path>>(
332        &mut self,
333        dir: P,
334        file_name: impl AsRef<str>,
335    ) -> Result<PathBuf> {
336        let dir = dir.as_ref().to_path_buf();
337        let file_name = file_name.as_ref().to_owned();
338        let target = build_safe_target(&dir, &file_name)?;
339        ensure_target_is_not_symlink(&target)?;
340        self.write_to_path(target.clone()).await?;
341        Ok(target)
342    }
343
344    async fn write_to_path(&mut self, path: PathBuf) -> Result<()> {
345        self.with_storage(move |mut storage| {
346            storage.seek(SeekFrom::Start(0))?;
347            let mut output = OpenOptions::new()
348                .write(true)
349                .create_new(true)
350                .open(&path)?;
351            std::io::copy(&mut storage, &mut output)?;
352            Ok((storage, ()))
353        })
354        .await
355    }
356}
357
358fn build_safe_target(dir: &Path, file_name: &str) -> Result<PathBuf> {
359    let name_path = Path::new(file_name);
360    let mut components = name_path.components();
361    let Some(Component::Normal(_)) = components.next() else {
362        return Err(
363            Error::bad_request("upload destination filename is not allowed")
364                .with_code("UPLOAD_PATH_INVALID"),
365        );
366    };
367    if components.next().is_some() {
368        return Err(
369            Error::bad_request("upload destination filename is not allowed")
370                .with_code("UPLOAD_PATH_INVALID"),
371        );
372    }
373    Ok(dir.join(file_name))
374}
375
376fn validate_save_path(path: &Path) -> Result<()> {
377    if path.is_absolute() {
378        return Err(Error::bad_request("upload destination path is not allowed")
379            .with_code("UPLOAD_PATH_INVALID"));
380    }
381
382    if path
383        .components()
384        .any(|component| matches!(component, Component::ParentDir))
385    {
386        return Err(Error::bad_request("upload destination path is not allowed")
387            .with_code("UPLOAD_PATH_INVALID"));
388    }
389
390    ensure_target_is_not_symlink(path)?;
391    Ok(())
392}
393
394fn ensure_target_is_not_symlink(path: &Path) -> Result<()> {
395    if let Ok(metadata) = std::fs::symlink_metadata(path) {
396        if metadata.file_type().is_symlink() {
397            return Err(Error::bad_request("upload destination path is not allowed")
398                .with_code("UPLOAD_PATH_SYMLINK"));
399        }
400    }
401    Ok(())
402}
403
404/// An `application/x-www-form-urlencoded` request body, deserialized and validated.
405///
406/// For form submissions without files. With files, use [`Multipart<T>`].
407pub struct Form<T>(pub T);
408
409impl<T> Form<T> {
410    /// Unwraps the parsed form value.
411    pub fn into_inner(self) -> T {
412        self.0
413    }
414}
415
416impl<T> FromRequest for Form<T>
417where
418    T: DeserializeOwned + Validate<Context = ()> + Send,
419{
420    fn from_request(
421        ctx: &RequestContext,
422    ) -> impl std::future::Future<Output = Result<Self>> + Send {
423        let taken = ctx.take_body();
424        let limit = crate::extract::body::configured_body_limit(ctx);
425        async move {
426            let bytes = crate::extract::body::read_body_capped_with(taken?, limit).await?;
427            let value: T = serde_urlencoded::from_bytes(&bytes)
428                .map_err(|_| Error::unprocessable("request body is not a valid form"))?;
429            value.validate().map_err(Error::from_garde_report)?;
430            Ok(Form(value))
431        }
432    }
433}
434
435/// Builds a value from a parsed multipart body.
436///
437/// Implemented by `#[derive(FormModel)]`, which binds each field (text or file)
438/// and runs its validation.
439pub trait FromMultipart: Sized {
440    /// Binds `Self` from the parsed multipart form.
441    fn from_multipart(
442        form: &mut MultipartForm,
443    ) -> impl std::future::Future<Output = Result<Self>> + Send;
444
445    /// Builds the OpenAPI/AsyncAPI schema for the form (overridden by the derive).
446    ///
447    /// The default is a permissive object; `#[derive(FormModel)]` generates a
448    /// precise schema with file fields marked as `format: binary`.
449    fn form_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
450        let _ = generator;
451        schemars::json_schema!({ "type": "object" })
452    }
453}
454
455/// A `multipart/form-data` request body bound to a form model.
456pub struct Multipart<T>(pub T);
457
458impl<T> Multipart<T> {
459    /// Unwraps the bound form value.
460    pub fn into_inner(self) -> T {
461        self.0
462    }
463}
464
465impl<T> FromRequest for Multipart<T>
466where
467    T: FromMultipart + Send,
468{
469    async fn from_request(ctx: &RequestContext) -> Result<Self> {
470        let mut form = __parse_multipart(ctx, UploadConfig::new()).await?;
471        let value = T::from_multipart(&mut form).await?;
472        Ok(Multipart(value))
473    }
474}
475
476/// Validation rules for a single file field, built by the form macros.
477///
478/// Generated-code support, not part of the everyday API.
479#[doc(hidden)]
480pub struct FileRule {
481    pub max_size: Option<usize>,
482    pub content_types: &'static [&'static str],
483    pub sniff: bool,
484}
485
486/// Validates a buffered file against its rule.
487#[doc(hidden)]
488pub fn __validate_file_bytes(file: &FileBytes, rule: &FileRule) -> Result<()> {
489    check_size(file.len(), rule)?;
490    check_declared_type(file.content_type(), rule)?;
491    if rule.sniff {
492        check_sniffed_type(file.bytes(), rule)?;
493    }
494    Ok(())
495}
496
497/// Validates a spooled upload against its rule (sniffing a small prefix).
498#[doc(hidden)]
499pub async fn __validate_upload(file: &mut UploadFile, rule: &FileRule) -> Result<()> {
500    check_size(file.size() as usize, rule)?;
501    check_declared_type(file.content_type(), rule)?;
502    if rule.sniff {
503        file.seek_start().await?;
504        let prefix = file.read_chunk(512).await?.unwrap_or_default();
505        file.seek_start().await?;
506        check_sniffed_type(&prefix, rule)?;
507    }
508    Ok(())
509}
510
511/// Rejects a file that exceeds the rule's size limit.
512fn check_size(size: usize, rule: &FileRule) -> Result<()> {
513    if let Some(max) = rule.max_size {
514        if size > max {
515            return Err(
516                Error::payload_too_large("uploaded file is too large").with_code("FILE_TOO_LARGE")
517            );
518        }
519    }
520    Ok(())
521}
522
523/// Rejects a file whose declared content type is not allowed.
524fn check_declared_type(declared: Option<&Mime>, rule: &FileRule) -> Result<()> {
525    if rule.content_types.is_empty() {
526        return Ok(());
527    }
528    let allowed = declared
529        .map(|mime| {
530            rule.content_types
531                .iter()
532                .any(|allowed| allowed.eq_ignore_ascii_case(mime.essence_str()))
533        })
534        .unwrap_or(false);
535    if !allowed {
536        return Err(Error::unprocessable("unsupported file content type")
537            .with_code("UNSUPPORTED_MEDIA_TYPE"));
538    }
539    Ok(())
540}
541
542/// Rejects a file whose sniffed (magic-byte) type is not allowed.
543fn check_sniffed_type(bytes: &[u8], rule: &FileRule) -> Result<()> {
544    if rule.content_types.is_empty() {
545        return Ok(());
546    }
547    if let Some(kind) = infer::get(bytes) {
548        let detected = kind.mime_type();
549        if !rule
550            .content_types
551            .iter()
552            .any(|allowed| allowed.eq_ignore_ascii_case(detected))
553        {
554            return Err(
555                Error::unprocessable("file content does not match the declared type")
556                    .with_code("UNSUPPORTED_MEDIA_TYPE"),
557            );
558        }
559    }
560    Ok(())
561}
562
563/// A single file part captured from a multipart body.
564struct FilePart {
565    name: String,
566    filename: Option<String>,
567    content_type: Option<Mime>,
568    storage: SpooledTempFile,
569    size: u64,
570}
571
572/// A parsed multipart body: its text fields and file parts.
573///
574/// This is generated-code support for the form macros, not part of the everyday
575/// API; handlers receive typed fields rather than this container.
576#[doc(hidden)]
577pub struct MultipartForm {
578    texts: Vec<(String, String)>,
579    files: Vec<FilePart>,
580}
581
582impl MultipartForm {
583    /// Parses the request body as `multipart/form-data` using `config`.
584    pub(crate) async fn parse(ctx: &RequestContext, config: &UploadConfig) -> Result<Self> {
585        let resolved = config.resolve();
586
587        let content_type = ctx
588            .headers()
589            .get(CONTENT_TYPE)
590            .and_then(|value| value.to_str().ok())
591            .ok_or_else(|| Error::bad_request("missing Content-Type for multipart form"))?;
592        let boundary = multer::parse_boundary(content_type)
593            .map_err(|_| Error::bad_request("invalid or missing multipart boundary"))?;
594
595        let body = ctx.take_body()?;
596        let mut multipart = multer::Multipart::new(BodyDataStream::new(body), boundary);
597
598        let mut texts = Vec::new();
599        let mut files = Vec::new();
600        let mut total: usize = 0;
601        let mut fields_seen: usize = 0;
602
603        while let Some(mut field) = multipart.next_field().await.map_err(parse_error)? {
604            // Cap the total number of parts so a flood of tiny fields cannot
605            // amplify allocations past what the byte-size limits bound.
606            fields_seen += 1;
607            if fields_seen > resolved.max_fields {
608                return Err(Error::unprocessable("too many form fields")
609                    .with_code("TOO_MANY_FIELDS"));
610            }
611            let name = field.name().map(str::to_owned).unwrap_or_default();
612            let filename = field.file_name().map(str::to_owned);
613            let content_type = field.content_type().cloned();
614
615            if filename.is_some() {
616                if files.len() >= resolved.max_files {
617                    return Err(
618                        Error::bad_request("too many file fields").with_code("TOO_MANY_FILES")
619                    );
620                }
621                let mut storage = resolved.new_spool();
622                let mut size: u64 = 0;
623                // Buffer chunks and flush them to the spool on a blocking thread,
624                // so writes that spill to disk do not block the async runtime.
625                let mut buffer: Vec<u8> = Vec::new();
626                while let Some(chunk) = field.chunk().await.map_err(parse_error)? {
627                    size += chunk.len() as u64;
628                    total += chunk.len();
629                    if size as usize > resolved.max_file_size {
630                        return Err(Error::payload_too_large("uploaded file is too large")
631                            .with_code("FILE_TOO_LARGE"));
632                    }
633                    if total > resolved.max_body_size {
634                        return Err(Error::payload_too_large("request body is too large"));
635                    }
636                    buffer.extend_from_slice(&chunk);
637                    if buffer.len() >= SPOOL_FLUSH_THRESHOLD {
638                        storage = spool_flush(storage, std::mem::take(&mut buffer), false).await?;
639                    }
640                }
641                // Flush the tail and rewind for reading, on a blocking thread.
642                storage = spool_flush(storage, buffer, true).await?;
643                files.push(FilePart {
644                    name,
645                    filename,
646                    content_type,
647                    storage,
648                    size,
649                });
650            } else {
651                let text = field.text().await.map_err(parse_error)?;
652                total += text.len();
653                if total > resolved.max_body_size {
654                    return Err(Error::payload_too_large("request body is too large"));
655                }
656                if text.len() > resolved.max_text_field_size {
657                    return Err(Error::payload_too_large("form field is too large")
658                        .with_code("FIELD_TOO_LARGE"));
659                }
660                texts.push((name, text));
661            }
662        }
663
664        Ok(Self { texts, files })
665    }
666
667    /// Removes and parses the first text field named `name`.
668    #[doc(hidden)]
669    pub fn take_form_value<T: FromStr>(&mut self, name: &str) -> Result<Option<T>> {
670        let Some(pos) = self.texts.iter().position(|(field, _)| field == name) else {
671            if self.files.iter().any(|file| file.name == name) {
672                return Err(Error::unprocessable(format!(
673                    "form field `{name}` is a file, not a text value"
674                )));
675            }
676            return Ok(None);
677        };
678        let (_, value) = self.texts.remove(pos);
679        value
680            .parse::<T>()
681            .map(Some)
682            .map_err(|_| Error::unprocessable(format!("form field `{name}` has an invalid value")))
683    }
684
685    /// Removes and parses every text field named `name`, in order.
686    #[doc(hidden)]
687    pub fn take_form_values<T: FromStr>(&mut self, name: &str) -> Result<Vec<T>> {
688        let mut values = Vec::new();
689        let mut index = 0;
690        while index < self.texts.len() {
691            if self.texts[index].0 == name {
692                let (_, value) = self.texts.remove(index);
693                let parsed = value.parse::<T>().map_err(|_| {
694                    Error::unprocessable(format!("form field `{name}` has an invalid value"))
695                })?;
696                values.push(parsed);
697            } else {
698                index += 1;
699            }
700        }
701        Ok(values)
702    }
703
704    /// Removes the first file field named `name`, buffering it into memory.
705    #[doc(hidden)]
706    pub async fn take_file_bytes(&mut self, name: &str) -> Result<Option<FileBytes>> {
707        let Some(pos) = self.files.iter().position(|file| file.name == name) else {
708            if self.texts.iter().any(|(field, _)| field == name) {
709                return Err(Error::unprocessable(format!(
710                    "form field `{name}` is a text value, not a file"
711                )));
712            }
713            return Ok(None);
714        };
715        Ok(Some(file_part_into_bytes(self.files.remove(pos)).await?))
716    }
717
718    /// Removes every file field named `name`, buffering each into memory.
719    #[doc(hidden)]
720    pub async fn take_file_bytes_list(&mut self, name: &str) -> Result<Vec<FileBytes>> {
721        let mut parts = Vec::new();
722        let mut index = 0;
723        while index < self.files.len() {
724            if self.files[index].name == name {
725                parts.push(self.files.remove(index));
726            } else {
727                index += 1;
728            }
729        }
730        let mut out = Vec::with_capacity(parts.len());
731        for part in parts {
732            out.push(file_part_into_bytes(part).await?);
733        }
734        Ok(out)
735    }
736
737    /// Removes the first file field named `name` as a spooled upload.
738    #[doc(hidden)]
739    pub fn take_upload_file(&mut self, name: &str) -> Option<UploadFile> {
740        let pos = self.files.iter().position(|file| file.name == name)?;
741        Some(file_part_into_upload(self.files.remove(pos)))
742    }
743
744    /// Removes every file field named `name` as spooled uploads.
745    #[doc(hidden)]
746    pub fn take_upload_file_list(&mut self, name: &str) -> Vec<UploadFile> {
747        let mut out = Vec::new();
748        let mut index = 0;
749        while index < self.files.len() {
750            if self.files[index].name == name {
751                out.push(file_part_into_upload(self.files.remove(index)));
752            } else {
753                index += 1;
754            }
755        }
756        out
757    }
758}
759
760/// Parses the request body as multipart, merging the route override over the
761/// application default upload configuration.
762///
763/// Generated-code support for the form macros.
764#[doc(hidden)]
765pub async fn __parse_multipart(ctx: &RequestContext, route: UploadConfig) -> Result<MultipartForm> {
766    let app_default = ctx
767        .state()
768        .get::<AppUploadConfig>()
769        .map(|config| config.0.clone())
770        .unwrap_or_default();
771    let config = route.merge(&app_default);
772    MultipartForm::parse(ctx, &config).await
773}
774
775/// Reads a spooled file part fully into memory.
776async fn file_part_into_bytes(part: FilePart) -> Result<FileBytes> {
777    let FilePart {
778        filename,
779        content_type,
780        mut storage,
781        ..
782    } = part;
783    let bytes = tokio::task::spawn_blocking(move || {
784        storage.seek(SeekFrom::Start(0))?;
785        let mut buffer = Vec::new();
786        storage.read_to_end(&mut buffer)?;
787        Ok::<_, std::io::Error>(Bytes::from(buffer))
788    })
789    .await
790    .map_err(|error| Error::internal(format!("upload IO task failed: {error}")))?
791    .map_err(|error| Error::internal(format!("upload IO error: {error}")))?;
792    Ok(FileBytes::new(bytes, filename, content_type))
793}
794
795/// Wraps a spooled file part as an [`UploadFile`].
796fn file_part_into_upload(part: FilePart) -> UploadFile {
797    UploadFile::new(part.filename, part.content_type, part.size, part.storage)
798}
799
800/// Maps a multer error to a `400 Bad Request`.
801fn parse_error(error: multer::Error) -> Error {
802    Error::bad_request(format!("multipart parse error: {error}"))
803}
804
805/// Writes `data` to the spool file on a blocking thread, optionally rewinding it
806/// for reading afterward, and returns the storage.
807///
808/// Spool writes can hit the disk once a file spills past the memory threshold, so
809/// they run via `spawn_blocking` rather than on the async runtime thread.
810async fn spool_flush(
811    mut storage: SpooledTempFile,
812    data: Vec<u8>,
813    rewind: bool,
814) -> Result<SpooledTempFile> {
815    tokio::task::spawn_blocking(move || -> std::io::Result<SpooledTempFile> {
816        if !data.is_empty() {
817            storage.write_all(&data)?;
818        }
819        if rewind {
820            storage.seek(SeekFrom::Start(0))?;
821        }
822        Ok(storage)
823    })
824    .await
825    .map_err(|error| Error::internal(format!("spool worker failed: {error}")))?
826    .map_err(|error| Error::internal(format!("spool write failed: {error}")))
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832    use schemars::SchemaGenerator;
833
834    fn spooled(data: &[u8]) -> SpooledTempFile {
835        let mut storage = SpooledTempFile::new(1024 * 1024);
836        storage.write_all(data).unwrap();
837        storage.seek(SeekFrom::Start(0)).unwrap();
838        storage
839    }
840
841    #[test]
842    fn file_bytes_reports_size_and_contents() {
843        let file = FileBytes::new(
844            Bytes::from_static(b"hello"),
845            Some("a.txt".to_owned()),
846            Some("text/plain".parse().unwrap()),
847        );
848        assert_eq!(file.len(), 5);
849        assert!(!file.is_empty());
850        assert_eq!(file.bytes(), b"hello");
851        assert_eq!(file.filename(), Some("a.txt"));
852        assert_eq!(
853            file.content_type().map(Mime::essence_str),
854            Some("text/plain")
855        );
856
857        let bytes = FileBytes::new(Bytes::from_static(b"hello"), None, None).into_bytes();
858        assert_eq!(bytes, Bytes::from_static(b"hello"));
859    }
860
861    #[test]
862    fn upload_config_builders_and_defaults_resolve() {
863        let dir = tempfile::tempdir().unwrap();
864        let config = UploadConfig::new()
865            .max_body_size(12)
866            .max_file_size(8)
867            .memory_threshold(4)
868            .max_files(2)
869            .temp_dir(dir.path());
870        let resolved = config.resolve();
871        assert_eq!(resolved.max_body_size, 12);
872        assert_eq!(resolved.max_file_size, 8);
873        assert_eq!(resolved.memory_threshold, 4);
874        assert_eq!(resolved.max_files, 2);
875        assert_eq!(config.temp_dir.as_deref(), Some(dir.path()));
876
877        let defaults = UploadConfig::new().resolve();
878        assert_eq!(defaults.max_body_size, DEFAULT_MAX_BODY_SIZE);
879        assert_eq!(defaults.max_file_size, DEFAULT_MAX_FILE_SIZE);
880        assert_eq!(defaults.memory_threshold, DEFAULT_MEMORY_THRESHOLD);
881        assert_eq!(defaults.max_files, DEFAULT_MAX_FILES);
882    }
883
884    #[tokio::test]
885    async fn upload_file_reads_and_saves() {
886        let mut file = UploadFile::new(Some("a.bin".to_owned()), None, 5, spooled(b"hello"));
887        assert_eq!(file.size(), 5);
888        assert_eq!(file.filename(), Some("a.bin"));
889        assert_eq!(file.read().await.unwrap(), Bytes::from_static(b"hello"));
890
891        let dir = tempfile::tempdir().unwrap();
892        let path = file.save_to_dir(dir.path(), "out.bin").await.unwrap();
893        assert_eq!(std::fs::read(&path).unwrap(), b"hello");
894    }
895
896    #[tokio::test]
897    async fn upload_file_reads_in_chunks() {
898        let mut file = UploadFile::new(None, None, 4, spooled(b"abcd"));
899        assert_eq!(
900            file.read_chunk(2).await.unwrap(),
901            Some(Bytes::from_static(b"ab"))
902        );
903        assert_eq!(
904            file.read_chunk(2).await.unwrap(),
905            Some(Bytes::from_static(b"cd"))
906        );
907        assert_eq!(file.read_chunk(2).await.unwrap(), None);
908        file.seek_start().await.unwrap();
909        assert_eq!(file.read().await.unwrap(), Bytes::from_static(b"abcd"));
910    }
911
912    #[tokio::test]
913    async fn upload_validation_covers_sniff_and_declared_type_paths() {
914        let mut file = UploadFile::new(
915            Some("a.png".to_owned()),
916            Some("image/png".parse().unwrap()),
917            16,
918            spooled(b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR"),
919        );
920        let rule = FileRule {
921            max_size: Some(1024),
922            content_types: &["image/png"],
923            sniff: true,
924        };
925        __validate_upload(&mut file, &rule).await.unwrap();
926
927        let mut wrong = UploadFile::new(
928            Some("a.txt".to_owned()),
929            Some("text/plain".parse().unwrap()),
930            2,
931            spooled(b"hi"),
932        );
933        let error = __validate_upload(&mut wrong, &rule).await.unwrap_err();
934        assert_eq!(error.code(), "UNSUPPORTED_MEDIA_TYPE");
935    }
936
937    #[tokio::test]
938    async fn upload_file_reports_consumed_storage() {
939        let mut file = UploadFile {
940            filename: None,
941            content_type: None,
942            size: 0,
943            storage: None,
944        };
945        let error = file.read().await.unwrap_err();
946        assert_eq!(error.message(), "upload file storage was already consumed");
947    }
948
949    #[test]
950    fn config_merge_prefers_route_over_app() {
951        let app = UploadConfig::new().max_file_size_mb(10).max_files(5);
952        let route = UploadConfig::new().max_file_size_mb(50);
953        let merged = route.merge(&app);
954        assert_eq!(merged.resolve().max_file_size, 50 * 1024 * 1024);
955        assert_eq!(merged.resolve().max_files, 5);
956    }
957
958    #[test]
959    fn default_form_schema_is_permissive_object() {
960        let mut generator = SchemaGenerator::default();
961        let schema = TokenForm::form_schema(&mut generator);
962        let schema_json = serde_json::to_value(&schema).unwrap();
963        assert_eq!(schema_json["type"], "object");
964    }
965
966    use crate::extract::PathParams;
967    use crate::state::StateMap;
968    use std::sync::Arc;
969
970    fn ctx_with(content_type: &str, body: &[u8]) -> RequestContext {
971        let head = http::Request::builder()
972            .header(CONTENT_TYPE, content_type)
973            .body(())
974            .unwrap()
975            .into_parts()
976            .0;
977        let body = crate::body::box_body(http_body_util::Full::new(Bytes::copy_from_slice(body)));
978        RequestContext::new(head, PathParams::new(), Arc::new(StateMap::new()), body)
979    }
980
981    #[derive(serde::Deserialize, garde::Validate)]
982    struct Login {
983        #[garde(length(min = 1))]
984        username: String,
985        #[garde(skip)]
986        password: String,
987    }
988
989    #[tokio::test]
990    async fn form_parses_urlencoded_body() {
991        let ctx = ctx_with(
992            "application/x-www-form-urlencoded",
993            b"username=ada&password=secret",
994        );
995        let form = Form::<Login>::from_request(&ctx).await.unwrap();
996        assert_eq!(form.0.username, "ada");
997        assert_eq!(form.0.password, "secret");
998        let login = form.into_inner();
999        assert_eq!(login.username, "ada");
1000    }
1001
1002    struct TokenForm {
1003        token: String,
1004    }
1005
1006    impl FromMultipart for TokenForm {
1007        async fn from_multipart(form: &mut MultipartForm) -> Result<Self> {
1008            let token = form
1009                .take_form_value::<String>("token")?
1010                .ok_or_else(|| Error::unprocessable("missing token"))?;
1011            Ok(TokenForm { token })
1012        }
1013    }
1014
1015    #[tokio::test]
1016    async fn multipart_binds_a_text_field_and_a_file() {
1017        let body = "--X\r\nContent-Disposition: form-data; name=\"token\"\r\n\r\nabc123\r\n\
1018                    --X\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\n\
1019                    Content-Type: text/plain\r\n\r\nhello\r\n--X--\r\n";
1020        let ctx = ctx_with("multipart/form-data; boundary=X", body.as_bytes());
1021
1022        // The text field binds via the model.
1023        let bound = Multipart::<TokenForm>::from_request(&ctx).await.unwrap();
1024        assert_eq!(bound.0.token, "abc123");
1025        assert_eq!(bound.into_inner().token, "abc123");
1026    }
1027
1028    #[tokio::test(flavor = "current_thread")]
1029    async fn multipart_spool_flush_does_not_block_the_runtime() {
1030        use std::sync::atomic::{AtomicUsize, Ordering};
1031        use std::sync::Arc;
1032        use std::time::Duration;
1033
1034        let body = format!(
1035            "--X\r\nContent-Disposition: form-data; name=\"file\"; filename=\"big.bin\"\r\n\
1036             Content-Type: application/octet-stream\r\n\r\n{}\r\n--X--\r\n",
1037            "a".repeat(SPOOL_FLUSH_THRESHOLD * 2)
1038        );
1039        let ctx = ctx_with("multipart/form-data; boundary=X", body.as_bytes());
1040        let ticks = Arc::new(AtomicUsize::new(0));
1041        let tick_counter = ticks.clone();
1042        let ticker = tokio::spawn(async move {
1043            loop {
1044                tick_counter.fetch_add(1, Ordering::Relaxed);
1045                tokio::time::sleep(Duration::from_millis(1)).await;
1046            }
1047        });
1048
1049        let mut form = __parse_multipart(
1050            &ctx,
1051            UploadConfig::new()
1052                .memory_threshold(1)
1053                .max_file_size(body.len() + 1024),
1054        )
1055        .await
1056        .unwrap();
1057        assert!(form.take_upload_file("file").is_some());
1058        ticker.abort();
1059        assert!(ticks.load(Ordering::Relaxed) > 0);
1060    }
1061
1062    #[test]
1063    fn file_validation_enforces_size_type_and_sniff() {
1064        let png_bytes = Bytes::from_static(b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR");
1065        let png = FileBytes::new(
1066            png_bytes,
1067            Some("a.png".to_owned()),
1068            Some("image/png".parse().unwrap()),
1069        );
1070        let rule = FileRule {
1071            max_size: Some(1024),
1072            content_types: &["image/png"],
1073            sniff: true,
1074        };
1075        assert!(__validate_file_bytes(&png, &rule).is_ok());
1076
1077        // Declared content type not allowed.
1078        let txt = FileBytes::new(
1079            Bytes::from_static(b"hi"),
1080            None,
1081            Some("text/plain".parse().unwrap()),
1082        );
1083        let only_png = FileRule {
1084            max_size: None,
1085            content_types: &["image/png"],
1086            sniff: false,
1087        };
1088        assert_eq!(
1089            __validate_file_bytes(&txt, &only_png).err().unwrap().code(),
1090            "UNSUPPORTED_MEDIA_TYPE"
1091        );
1092
1093        // Too large.
1094        let big = FileBytes::new(Bytes::from(vec![0u8; 100]), None, None);
1095        let small_limit = FileRule {
1096            max_size: Some(10),
1097            content_types: &[],
1098            sniff: false,
1099        };
1100        assert_eq!(
1101            __validate_file_bytes(&big, &small_limit)
1102                .err()
1103                .unwrap()
1104                .code(),
1105            "FILE_TOO_LARGE"
1106        );
1107
1108        // Sniff mismatch: declared png but bytes are not png.
1109        let fake = FileBytes::new(
1110            Bytes::from_static(b"GIF89a...."),
1111            None,
1112            Some("image/png".parse().unwrap()),
1113        );
1114        assert!(__validate_file_bytes(&fake, &rule).is_err());
1115
1116        let unrestricted = FileRule {
1117            max_size: None,
1118            content_types: &[],
1119            sniff: true,
1120        };
1121        assert!(__validate_file_bytes(&txt, &unrestricted).is_ok());
1122    }
1123
1124    #[tokio::test]
1125    async fn multipart_form_takes_files_and_values() {
1126        let body = "--X\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nhi\r\n\
1127                    --X\r\nContent-Disposition: form-data; name=\"count\"\r\n\r\n1\r\n\
1128                    --X\r\nContent-Disposition: form-data; name=\"count\"\r\n\r\n2\r\n\
1129                    --X\r\nContent-Disposition: form-data; name=\"doc\"; filename=\"a.bin\"\r\n\r\nDATA\r\n\
1130                    --X\r\nContent-Disposition: form-data; name=\"doc\"; filename=\"b.bin\"\r\n\r\nMORE\r\n--X--\r\n";
1131        let ctx = ctx_with("multipart/form-data; boundary=X", body.as_bytes());
1132        let mut form = __parse_multipart(&ctx, UploadConfig::new()).await.unwrap();
1133
1134        let file = form
1135            .take_file_bytes("doc")
1136            .await
1137            .unwrap()
1138            .expect("file present");
1139        assert_eq!(file.bytes(), b"DATA");
1140        assert_eq!(file.filename(), Some("a.bin"));
1141        assert_eq!(
1142            form.take_form_value::<String>("note").unwrap(),
1143            Some("hi".to_owned())
1144        );
1145        assert_eq!(form.take_form_value::<String>("missing").unwrap(), None);
1146        assert_eq!(form.take_form_values::<u32>("count").unwrap(), vec![1, 2]);
1147        assert_eq!(
1148            form.take_form_values::<u32>("count").unwrap(),
1149            Vec::<u32>::new()
1150        );
1151
1152        let remaining = form.take_file_bytes_list("doc").await.unwrap();
1153        assert_eq!(remaining.len(), 1);
1154        assert_eq!(remaining[0].bytes(), b"MORE");
1155        assert!(form.take_file_bytes("doc").await.unwrap().is_none());
1156        assert!(form.take_upload_file("doc").is_none());
1157        assert!(form.take_upload_file_list("doc").is_empty());
1158    }
1159
1160    #[tokio::test]
1161    async fn multipart_form_rejects_invalid_values_and_limits() {
1162        let body = "--X\r\nContent-Disposition: form-data; name=\"count\"\r\n\r\nabc\r\n--X--\r\n";
1163        let ctx = ctx_with("multipart/form-data; boundary=X", body.as_bytes());
1164        let mut form = __parse_multipart(&ctx, UploadConfig::new()).await.unwrap();
1165        assert_eq!(
1166            form.take_form_value::<u32>("count").unwrap_err().kind(),
1167            crate::error::ErrorKind::Unprocessable
1168        );
1169
1170        let too_many = "--X\r\nContent-Disposition: form-data; name=\"a\"; filename=\"a.txt\"\r\n\r\nA\r\n\
1171                        --X\r\nContent-Disposition: form-data; name=\"b\"; filename=\"b.txt\"\r\n\r\nB\r\n--X--\r\n";
1172        let ctx = ctx_with("multipart/form-data; boundary=X", too_many.as_bytes());
1173        let error = match __parse_multipart(&ctx, UploadConfig::new().max_files(1)).await {
1174            Ok(_) => panic!("expected too many files error"),
1175            Err(error) => error,
1176        };
1177        assert_eq!(error.code(), "TOO_MANY_FILES");
1178
1179        let oversized_text =
1180            "--X\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nhello\r\n--X--\r\n";
1181        let ctx = ctx_with("multipart/form-data; boundary=X", oversized_text.as_bytes());
1182        let error = match __parse_multipart(&ctx, UploadConfig::new().max_body_size(3)).await {
1183            Ok(_) => panic!("expected payload too large"),
1184            Err(error) => error,
1185        };
1186        assert_eq!(error.kind(), crate::error::ErrorKind::PayloadTooLarge);
1187
1188        let oversized_file = "--X\r\nContent-Disposition: form-data; name=\"doc\"; filename=\"a.txt\"\r\n\r\nhello\r\n--X--\r\n";
1189        let ctx = ctx_with("multipart/form-data; boundary=X", oversized_file.as_bytes());
1190        let error = match __parse_multipart(&ctx, UploadConfig::new().max_file_size(3)).await {
1191            Ok(_) => panic!("expected file too large"),
1192            Err(error) => error,
1193        };
1194        assert_eq!(error.code(), "FILE_TOO_LARGE");
1195    }
1196
1197    #[tokio::test]
1198    async fn multipart_parse_reports_content_type_errors() {
1199        let request = http::Request::builder().body(()).unwrap().into_parts().0;
1200        let body = crate::body::box_body(http_body_util::Full::new(Bytes::new()));
1201        let ctx = RequestContext::new(request, PathParams::new(), Arc::new(StateMap::new()), body);
1202        let error = match MultipartForm::parse(&ctx, &UploadConfig::new()).await {
1203            Ok(_) => panic!("expected missing content type"),
1204            Err(error) => error,
1205        };
1206        assert_eq!(error.message(), "missing Content-Type for multipart form");
1207
1208        let ctx = ctx_with("multipart/form-data", b"");
1209        let error = match MultipartForm::parse(&ctx, &UploadConfig::new()).await {
1210            Ok(_) => panic!("expected invalid boundary"),
1211            Err(error) => error,
1212        };
1213        assert_eq!(error.message(), "invalid or missing multipart boundary");
1214    }
1215
1216    #[tokio::test]
1217    async fn parse_multipart_merges_route_and_app_config() {
1218        let body = "--X\r\nContent-Disposition: form-data; name=\"doc\"; filename=\"a.txt\"\r\n\r\nhello\r\n--X--\r\n";
1219        let head = http::Request::builder()
1220            .header(CONTENT_TYPE, "multipart/form-data; boundary=X")
1221            .body(())
1222            .unwrap()
1223            .into_parts()
1224            .0;
1225        let mut state = StateMap::new();
1226        state.insert(AppUploadConfig(UploadConfig::new().max_file_size(3)));
1227        let body = crate::body::box_body(http_body_util::Full::new(Bytes::copy_from_slice(
1228            body.as_bytes(),
1229        )));
1230        let ctx = RequestContext::new(head, PathParams::new(), Arc::new(state), body);
1231        let mut form = __parse_multipart(&ctx, UploadConfig::new().max_file_size(10))
1232            .await
1233            .unwrap();
1234        assert!(form.take_upload_file("doc").is_some());
1235    }
1236
1237    #[test]
1238    fn parse_error_includes_multipart_context() {
1239        let error = parse_error(multer::Error::IncompleteStream);
1240        assert!(error.message().starts_with("multipart parse error:"));
1241    }
1242}