Skip to main content

fastapi_core/
multipart.rs

1//! Multipart form data parser.
2//!
3//! Provides parsing of `multipart/form-data` request bodies, commonly used for file uploads.
4//! The parser enforces per-file and total size limits.
5
6use std::collections::HashMap;
7use std::fs::OpenOptions;
8use std::io::{Read, Seek, SeekFrom, Write};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13/// Default maximum file size (10MB).
14pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
15
16/// Default maximum total upload size (50MB).
17pub const DEFAULT_MAX_TOTAL_SIZE: usize = 50 * 1024 * 1024;
18
19/// Default maximum number of fields.
20pub const DEFAULT_MAX_FIELDS: usize = 100;
21
22/// Default threshold for spooling uploads to a temporary file (1MB).
23pub const DEFAULT_SPOOL_THRESHOLD: usize = 1024 * 1024;
24/// RFC 2046 recommends multipart boundary length <= 70 characters.
25const MAX_BOUNDARY_LEN: usize = 70;
26
27/// Configuration for multipart parsing.
28#[derive(Debug, Clone)]
29pub struct MultipartConfig {
30    /// Maximum size per file in bytes.
31    max_file_size: usize,
32    /// Maximum total upload size in bytes.
33    max_total_size: usize,
34    /// Maximum number of fields (including files).
35    max_fields: usize,
36    /// Threshold above which uploaded files are spooled to a temporary file.
37    spool_threshold: usize,
38}
39
40impl Default for MultipartConfig {
41    fn default() -> Self {
42        Self {
43            max_file_size: DEFAULT_MAX_FILE_SIZE,
44            max_total_size: DEFAULT_MAX_TOTAL_SIZE,
45            max_fields: DEFAULT_MAX_FIELDS,
46            spool_threshold: DEFAULT_SPOOL_THRESHOLD,
47        }
48    }
49}
50
51impl MultipartConfig {
52    /// Create a new configuration with default settings.
53    #[must_use]
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Set the maximum file size.
59    #[must_use]
60    pub fn max_file_size(mut self, size: usize) -> Self {
61        self.max_file_size = size;
62        self
63    }
64
65    /// Set the maximum total upload size.
66    #[must_use]
67    pub fn max_total_size(mut self, size: usize) -> Self {
68        self.max_total_size = size;
69        self
70    }
71
72    /// Set the maximum number of fields.
73    #[must_use]
74    pub fn max_fields(mut self, count: usize) -> Self {
75        self.max_fields = count;
76        self
77    }
78
79    /// Set the threshold above which files are spooled to a temporary file.
80    #[must_use]
81    pub fn spool_threshold(mut self, size: usize) -> Self {
82        self.spool_threshold = size;
83        self
84    }
85
86    /// Get the maximum file size.
87    #[must_use]
88    pub fn get_max_file_size(&self) -> usize {
89        self.max_file_size
90    }
91
92    /// Get the maximum total upload size.
93    #[must_use]
94    pub fn get_max_total_size(&self) -> usize {
95        self.max_total_size
96    }
97
98    /// Get the maximum number of fields.
99    #[must_use]
100    pub fn get_max_fields(&self) -> usize {
101        self.max_fields
102    }
103
104    /// Get the spool-to-disk threshold.
105    #[must_use]
106    pub fn get_spool_threshold(&self) -> usize {
107        self.spool_threshold
108    }
109}
110
111/// Errors that can occur during multipart parsing.
112#[derive(Debug)]
113pub enum MultipartError {
114    /// Missing boundary in Content-Type header.
115    MissingBoundary,
116    /// Invalid boundary format.
117    InvalidBoundary,
118    /// File size exceeds limit.
119    FileTooLarge { size: usize, max: usize },
120    /// Total upload size exceeds limit.
121    TotalTooLarge { size: usize, max: usize },
122    /// Too many fields.
123    TooManyFields { count: usize, max: usize },
124    /// Missing Content-Disposition header.
125    MissingContentDisposition,
126    /// Invalid Content-Disposition header.
127    InvalidContentDisposition { detail: String },
128    /// Invalid part headers.
129    InvalidPartHeaders { detail: String },
130    /// Unexpected end of input.
131    UnexpectedEof,
132    /// Invalid multipart format.
133    InvalidFormat { detail: &'static str },
134    /// I/O error while spooling streamed part data.
135    Io { detail: String },
136}
137
138impl std::fmt::Display for MultipartError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            Self::MissingBoundary => write!(f, "missing boundary in multipart Content-Type"),
142            Self::InvalidBoundary => write!(f, "invalid multipart boundary"),
143            Self::FileTooLarge { size, max } => {
144                write!(f, "file too large: {size} bytes exceeds limit of {max}")
145            }
146            Self::TotalTooLarge { size, max } => {
147                write!(
148                    f,
149                    "total upload too large: {size} bytes exceeds limit of {max}"
150                )
151            }
152            Self::TooManyFields { count, max } => {
153                write!(f, "too many fields: {count} exceeds limit of {max}")
154            }
155            Self::MissingContentDisposition => {
156                write!(f, "missing Content-Disposition header in part")
157            }
158            Self::InvalidContentDisposition { detail } => {
159                write!(f, "invalid Content-Disposition: {detail}")
160            }
161            Self::InvalidPartHeaders { detail } => write!(f, "invalid part headers: {detail}"),
162            Self::UnexpectedEof => write!(f, "unexpected end of multipart data"),
163            Self::InvalidFormat { detail } => write!(f, "invalid multipart format: {detail}"),
164            Self::Io { detail } => write!(f, "multipart I/O error: {detail}"),
165        }
166    }
167}
168
169impl std::error::Error for MultipartError {}
170
171/// A parsed multipart form part.
172#[derive(Debug, Clone)]
173pub struct Part {
174    /// Field name from Content-Disposition.
175    pub name: String,
176    /// Filename from Content-Disposition (if present).
177    pub filename: Option<String>,
178    /// Content-Type of the part (if present).
179    pub content_type: Option<String>,
180    /// The part's content.
181    pub data: Vec<u8>,
182    /// Additional headers.
183    pub headers: HashMap<String, String>,
184    spooled_path: Option<PathBuf>,
185    spooled_len: Option<usize>,
186}
187
188impl Part {
189    /// Returns true if this part is a file upload.
190    #[must_use]
191    pub fn is_file(&self) -> bool {
192        self.filename.is_some()
193    }
194
195    /// Returns true if this part is a regular form field.
196    #[must_use]
197    pub fn is_field(&self) -> bool {
198        self.filename.is_none()
199    }
200
201    /// Get the content as a UTF-8 string (for form fields).
202    ///
203    /// Returns `None` if the content is not valid UTF-8.
204    #[must_use]
205    pub fn text(&self) -> Option<&str> {
206        std::str::from_utf8(&self.data).ok()
207    }
208
209    /// Get the size of the data in bytes.
210    #[must_use]
211    pub fn size(&self) -> usize {
212        self.spooled_len.unwrap_or(self.data.len())
213    }
214
215    /// Returns true when this part's data is backed by a spooled temp file.
216    #[must_use]
217    pub fn is_spooled(&self) -> bool {
218        self.spooled_path.is_some()
219    }
220
221    /// Path to the spooled temporary file, if this part is backed by disk.
222    #[must_use]
223    pub fn spooled_path(&self) -> Option<&Path> {
224        self.spooled_path.as_deref()
225    }
226
227    /// Read part bytes regardless of in-memory or spooled backing.
228    pub fn bytes(&self) -> std::io::Result<Vec<u8>> {
229        if let Some(path) = &self.spooled_path {
230            std::fs::read(path)
231        } else {
232            Ok(self.data.clone())
233        }
234    }
235}
236
237#[derive(Debug)]
238enum UploadStorage {
239    InMemory(Vec<u8>),
240    SpooledTempFile { path: PathBuf, len: u64 },
241}
242
243/// An uploaded file with metadata and FastAPI-style async file operations.
244#[derive(Debug)]
245pub struct UploadFile {
246    /// The field name.
247    pub field_name: String,
248    /// The original filename.
249    pub filename: String,
250    /// Content-Type of the file.
251    pub content_type: String,
252    storage: UploadStorage,
253    cursor: u64,
254    closed: bool,
255}
256
257impl UploadFile {
258    /// Create a new UploadFile from a Part.
259    ///
260    /// Returns `None` if the part is not a file.
261    #[must_use]
262    pub fn from_part(part: Part) -> Option<Self> {
263        Self::from_part_with_spool_threshold(part, DEFAULT_SPOOL_THRESHOLD)
264    }
265
266    /// Create a new UploadFile from a Part with a custom spool threshold.
267    ///
268    /// Returns `None` if the part is not a file.
269    #[must_use]
270    pub fn from_part_with_spool_threshold(part: Part, spool_threshold: usize) -> Option<Self> {
271        let Part {
272            name,
273            filename,
274            content_type,
275            data,
276            headers: _,
277            spooled_path,
278            spooled_len,
279        } = part;
280        let filename = filename?;
281
282        let storage = if let Some(path) = spooled_path {
283            UploadStorage::SpooledTempFile {
284                path,
285                len: u64::try_from(spooled_len.unwrap_or(data.len())).unwrap_or(u64::MAX),
286            }
287        } else if data.len() > spool_threshold {
288            match spool_to_tempfile(&data) {
289                Ok(path) => UploadStorage::SpooledTempFile {
290                    path,
291                    len: u64::try_from(data.len()).unwrap_or(u64::MAX),
292                },
293                Err(_) => UploadStorage::InMemory(data),
294            }
295        } else {
296            UploadStorage::InMemory(data)
297        };
298
299        Some(Self {
300            field_name: name,
301            filename,
302            content_type: content_type.unwrap_or_else(|| "application/octet-stream".to_string()),
303            storage,
304            cursor: 0,
305            closed: false,
306        })
307    }
308
309    /// Get the file size in bytes.
310    #[must_use]
311    pub fn size(&self) -> usize {
312        match &self.storage {
313            UploadStorage::InMemory(data) => data.len(),
314            UploadStorage::SpooledTempFile { len, .. } => {
315                usize::try_from(*len).unwrap_or(usize::MAX)
316            }
317        }
318    }
319
320    /// Returns true when this file has been spooled to a temporary file.
321    #[must_use]
322    pub fn is_spooled(&self) -> bool {
323        matches!(self.storage, UploadStorage::SpooledTempFile { .. })
324    }
325
326    /// Path to the spooled temporary file, if this upload is backed by disk.
327    #[must_use]
328    pub fn spooled_path(&self) -> Option<&Path> {
329        match &self.storage {
330            UploadStorage::InMemory(_) => None,
331            UploadStorage::SpooledTempFile { path, .. } => Some(path.as_path()),
332        }
333    }
334
335    /// Read file contents without changing the current cursor.
336    pub fn bytes(&self) -> std::io::Result<Vec<u8>> {
337        match &self.storage {
338            UploadStorage::InMemory(data) => Ok(data.clone()),
339            UploadStorage::SpooledTempFile { path, .. } => std::fs::read(path),
340        }
341    }
342
343    /// Read from the current cursor position.
344    ///
345    /// - `size = Some(n)`: read up to `n` bytes
346    /// - `size = None`: read until EOF
347    pub async fn read(&mut self, size: Option<usize>) -> std::io::Result<Vec<u8>> {
348        self.ensure_open()?;
349
350        match &mut self.storage {
351            UploadStorage::InMemory(data) => {
352                let start = usize::try_from(self.cursor).unwrap_or(usize::MAX);
353                if start >= data.len() {
354                    return Ok(Vec::new());
355                }
356
357                let end = match size {
358                    Some(n) => start.saturating_add(n).min(data.len()),
359                    None => data.len(),
360                };
361                self.cursor = u64::try_from(end).unwrap_or(u64::MAX);
362                Ok(data[start..end].to_vec())
363            }
364            UploadStorage::SpooledTempFile { path, len } => {
365                let mut file = std::fs::File::open(path)?;
366                file.seek(SeekFrom::Start(self.cursor))?;
367
368                let max_to_read = match size {
369                    Some(n) => u64::try_from(n).unwrap_or(u64::MAX),
370                    None => len.saturating_sub(self.cursor),
371                };
372
373                let mut reader = file.take(max_to_read);
374                let mut out = Vec::new();
375                reader.read_to_end(&mut out)?;
376                self.cursor = self
377                    .cursor
378                    .saturating_add(u64::try_from(out.len()).unwrap_or(u64::MAX));
379                Ok(out)
380            }
381        }
382    }
383
384    /// Write bytes at the current cursor position.
385    ///
386    /// Returns the number of bytes written.
387    pub async fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
388        self.ensure_open()?;
389        if bytes.is_empty() {
390            return Ok(0);
391        }
392
393        match &mut self.storage {
394            UploadStorage::InMemory(data) => {
395                let start = usize::try_from(self.cursor).unwrap_or(usize::MAX);
396                if start > data.len() {
397                    data.resize(start, 0);
398                }
399
400                let end = start.saturating_add(bytes.len());
401                if end > data.len() {
402                    data.resize(end, 0);
403                }
404                data[start..end].copy_from_slice(bytes);
405                self.cursor = u64::try_from(end).unwrap_or(u64::MAX);
406                Ok(bytes.len())
407            }
408            UploadStorage::SpooledTempFile { path, len } => {
409                let mut file = OpenOptions::new().read(true).write(true).open(path)?;
410                file.seek(SeekFrom::Start(self.cursor))?;
411                file.write_all(bytes)?;
412                self.cursor = self
413                    .cursor
414                    .saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
415                if self.cursor > *len {
416                    *len = self.cursor;
417                }
418                Ok(bytes.len())
419            }
420        }
421    }
422
423    /// Move the current cursor.
424    pub async fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
425        self.ensure_open()?;
426        let new_cursor = resolve_seek(self.cursor, self.len_u64(), position)?;
427        self.cursor = new_cursor;
428        Ok(new_cursor)
429    }
430
431    /// Close the file handle and clean up any temporary storage.
432    pub async fn close(&mut self) -> std::io::Result<()> {
433        if self.closed {
434            return Ok(());
435        }
436
437        if let UploadStorage::SpooledTempFile { path, .. } = &self.storage {
438            match std::fs::remove_file(path) {
439                Ok(()) => {}
440                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
441                Err(err) => return Err(err),
442            }
443        }
444        self.closed = true;
445        Ok(())
446    }
447
448    /// Get the file extension from the filename.
449    #[must_use]
450    pub fn extension(&self) -> Option<&str> {
451        self.filename
452            .rsplit('.')
453            .next()
454            .filter(|ext| !ext.is_empty() && *ext != self.filename)
455    }
456
457    fn ensure_open(&self) -> std::io::Result<()> {
458        if self.closed {
459            Err(std::io::Error::other("upload file is closed"))
460        } else {
461            Ok(())
462        }
463    }
464
465    fn len_u64(&self) -> u64 {
466        match &self.storage {
467            UploadStorage::InMemory(data) => u64::try_from(data.len()).unwrap_or(u64::MAX),
468            UploadStorage::SpooledTempFile { len, .. } => *len,
469        }
470    }
471}
472
473impl Drop for UploadFile {
474    fn drop(&mut self) {
475        if self.closed {
476            return;
477        }
478        if let UploadStorage::SpooledTempFile { path, .. } = &self.storage {
479            let _ = std::fs::remove_file(path);
480        }
481    }
482}
483
484fn resolve_seek(current: u64, len: u64, position: SeekFrom) -> std::io::Result<u64> {
485    let next = match position {
486        SeekFrom::Start(offset) => i128::from(offset),
487        SeekFrom::End(offset) => i128::from(len) + i128::from(offset),
488        SeekFrom::Current(offset) => i128::from(current) + i128::from(offset),
489    };
490
491    if next < 0 {
492        return Err(std::io::Error::new(
493            std::io::ErrorKind::InvalidInput,
494            "seek before start of file",
495        ));
496    }
497
498    u64::try_from(next).map_err(|_| {
499        std::io::Error::new(
500            std::io::ErrorKind::InvalidInput,
501            "seek target exceeds addressable range",
502        )
503    })
504}
505
506static UPLOAD_SPOOL_COUNTER: AtomicU64 = AtomicU64::new(1);
507
508fn create_spool_tempfile() -> std::io::Result<(PathBuf, std::fs::File)> {
509    let temp_dir = std::env::temp_dir();
510    let ts_nanos = SystemTime::now()
511        .duration_since(UNIX_EPOCH)
512        .unwrap_or_default()
513        .as_nanos();
514
515    for _ in 0..32 {
516        let counter = UPLOAD_SPOOL_COUNTER.fetch_add(1, Ordering::Relaxed);
517        let candidate = temp_dir.join(format!(
518            "fastapi-rust-upload-{}-{ts_nanos}-{counter}.tmp",
519            std::process::id()
520        ));
521
522        match OpenOptions::new()
523            .create_new(true)
524            .write(true)
525            .open(&candidate)
526        {
527            Ok(file) => return Ok((candidate, file)),
528            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
529            Err(err) => return Err(err),
530        }
531    }
532
533    Err(std::io::Error::new(
534        std::io::ErrorKind::AlreadyExists,
535        "failed to allocate unique spool file",
536    ))
537}
538
539fn spool_to_tempfile(data: &[u8]) -> std::io::Result<PathBuf> {
540    let (path, mut file) = create_spool_tempfile()?;
541    file.write_all(data)?;
542    Ok(path)
543}
544
545/// Parse boundary from Content-Type header.
546///
547/// Content-Type format: `multipart/form-data; boundary=----WebKitFormBoundary...`
548pub fn parse_boundary(content_type: &str) -> Result<String, MultipartError> {
549    let content_type = content_type.trim();
550    let main = content_type.split(';').next().unwrap_or("").trim();
551    if !main.eq_ignore_ascii_case("multipart/form-data") {
552        return Err(MultipartError::InvalidBoundary);
553    }
554
555    for part in content_type.split(';').skip(1) {
556        let part = part.trim();
557        let Some((k, v)) = part.split_once('=') else {
558            continue;
559        };
560        if k.trim().eq_ignore_ascii_case("boundary") {
561            let boundary = v.trim();
562            let boundary = boundary.trim_matches('"').trim_matches('\'');
563            if boundary.is_empty() || boundary.len() > MAX_BOUNDARY_LEN {
564                return Err(MultipartError::InvalidBoundary);
565            }
566            return Ok(boundary.to_string());
567        }
568    }
569
570    Err(MultipartError::MissingBoundary)
571}
572
573/// Multipart parser (boundary-based).
574#[derive(Debug)]
575pub struct MultipartParser {
576    boundary: Vec<u8>,
577    config: MultipartConfig,
578}
579
580/// Incremental parser state for streamed multipart bodies.
581#[derive(Debug, Default)]
582pub struct MultipartStreamState {
583    started: bool,
584    done: bool,
585    part_count: usize,
586    total_size: usize,
587    current_part: Option<StreamingPartState>,
588}
589
590#[derive(Debug, Clone)]
591enum PartStreamingStorage {
592    InMemory(Vec<u8>),
593    SpooledTempFile { path: PathBuf, len: usize },
594}
595
596#[derive(Debug, Clone)]
597struct StreamingPartState {
598    name: String,
599    filename: Option<String>,
600    content_type: Option<String>,
601    headers: HashMap<String, String>,
602    size: usize,
603    storage: PartStreamingStorage,
604}
605
606impl StreamingPartState {
607    fn new(
608        name: String,
609        filename: Option<String>,
610        content_type: Option<String>,
611        headers: HashMap<String, String>,
612    ) -> Self {
613        Self {
614            name,
615            filename,
616            content_type,
617            headers,
618            size: 0,
619            storage: PartStreamingStorage::InMemory(Vec::new()),
620        }
621    }
622
623    fn append(
624        &mut self,
625        chunk: &[u8],
626        config: &MultipartConfig,
627        total_size: &mut usize,
628    ) -> Result<(), MultipartError> {
629        if chunk.is_empty() {
630            return Ok(());
631        }
632
633        let next_size = self.size.saturating_add(chunk.len());
634        if self.filename.is_some() && next_size > config.max_file_size {
635            return Err(MultipartError::FileTooLarge {
636                size: next_size,
637                max: config.max_file_size,
638            });
639        }
640
641        let next_total = total_size.saturating_add(chunk.len());
642        if next_total > config.max_total_size {
643            return Err(MultipartError::TotalTooLarge {
644                size: next_total,
645                max: config.max_total_size,
646            });
647        }
648
649        match &mut self.storage {
650            PartStreamingStorage::InMemory(data) => {
651                if self.filename.is_some() && next_size > config.spool_threshold {
652                    let (path, mut file) =
653                        create_spool_tempfile().map_err(|e| MultipartError::Io {
654                            detail: format!("failed to create spool tempfile: {e}"),
655                        })?;
656                    file.write_all(data).map_err(|e| MultipartError::Io {
657                        detail: format!("failed to write spool tempfile: {e}"),
658                    })?;
659                    file.write_all(chunk).map_err(|e| MultipartError::Io {
660                        detail: format!("failed to write spool tempfile: {e}"),
661                    })?;
662                    self.storage = PartStreamingStorage::SpooledTempFile {
663                        path,
664                        len: next_size,
665                    };
666                } else {
667                    data.extend_from_slice(chunk);
668                }
669            }
670            PartStreamingStorage::SpooledTempFile { path, len } => {
671                let mut file =
672                    OpenOptions::new()
673                        .append(true)
674                        .open(path)
675                        .map_err(|e| MultipartError::Io {
676                            detail: format!("failed to open spool tempfile for append: {e}"),
677                        })?;
678                file.write_all(chunk).map_err(|e| MultipartError::Io {
679                    detail: format!("failed to append spool tempfile: {e}"),
680                })?;
681                *len = next_size;
682            }
683        }
684
685        self.size = next_size;
686        *total_size = next_total;
687        Ok(())
688    }
689
690    fn into_part(mut self) -> Part {
691        let storage = std::mem::replace(
692            &mut self.storage,
693            PartStreamingStorage::InMemory(Vec::new()),
694        );
695        let (data, spooled_path, spooled_len) = match storage {
696            PartStreamingStorage::InMemory(data) => {
697                let len = data.len();
698                (data, None, Some(len))
699            }
700            PartStreamingStorage::SpooledTempFile { path, len } => {
701                (Vec::new(), Some(path), Some(len))
702            }
703        };
704
705        Part {
706            name: std::mem::take(&mut self.name),
707            filename: std::mem::take(&mut self.filename),
708            content_type: std::mem::take(&mut self.content_type),
709            data,
710            headers: std::mem::take(&mut self.headers),
711            spooled_path,
712            spooled_len,
713        }
714    }
715}
716
717impl Drop for StreamingPartState {
718    fn drop(&mut self) {
719        if let PartStreamingStorage::SpooledTempFile { path, .. } = &self.storage {
720            let _ = std::fs::remove_file(path);
721        }
722    }
723}
724
725impl MultipartStreamState {
726    /// Returns true if the closing boundary has been fully parsed.
727    #[must_use]
728    pub fn is_done(&self) -> bool {
729        self.done
730    }
731}
732
733impl MultipartParser {
734    /// Create a new parser with the given boundary.
735    #[must_use]
736    pub fn new(boundary: &str, config: MultipartConfig) -> Self {
737        Self {
738            boundary: format!("--{boundary}").into_bytes(),
739            config,
740        }
741    }
742
743    /// Parse all parts from the body.
744    pub fn parse(&self, body: &[u8]) -> Result<Vec<Part>, MultipartError> {
745        let mut parts = Vec::new();
746        let mut total_size = 0usize;
747        let mut pos = 0;
748
749        // Skip preamble and find first boundary
750        pos = self.find_boundary_from(body, pos)?;
751
752        loop {
753            if parts.len() >= self.config.max_fields {
754                return Err(MultipartError::TooManyFields {
755                    count: parts.len() + 1,
756                    max: self.config.max_fields,
757                });
758            }
759
760            let boundary_end = pos + self.boundary.len();
761            if boundary_end + 2 <= body.len() && body[boundary_end..boundary_end + 2] == *b"--" {
762                break;
763            }
764
765            pos = boundary_end;
766            if pos + 2 > body.len() {
767                return Err(MultipartError::UnexpectedEof);
768            }
769            if body[pos..pos + 2] != *b"\r\n" {
770                return Err(MultipartError::InvalidFormat {
771                    detail: "expected CRLF after boundary",
772                });
773            }
774            pos += 2;
775
776            let (headers, header_end) = self.parse_part_headers(body, pos)?;
777            pos = header_end;
778
779            let content_disp = headers
780                .get("content-disposition")
781                .ok_or(MultipartError::MissingContentDisposition)?;
782            let (name, filename) = parse_content_disposition(content_disp)?;
783            let content_type = headers.get("content-type").cloned();
784
785            let data_end = self.find_boundary_from(body, pos)?;
786            let data = if data_end >= 2 && body[data_end - 2..data_end] == *b"\r\n" {
787                &body[pos..data_end - 2]
788            } else {
789                &body[pos..data_end]
790            };
791
792            if filename.is_some() && data.len() > self.config.max_file_size {
793                return Err(MultipartError::FileTooLarge {
794                    size: data.len(),
795                    max: self.config.max_file_size,
796                });
797            }
798
799            total_size += data.len();
800            if total_size > self.config.max_total_size {
801                return Err(MultipartError::TotalTooLarge {
802                    size: total_size,
803                    max: self.config.max_total_size,
804                });
805            }
806
807            parts.push(Part {
808                name,
809                filename,
810                content_type,
811                data: data.to_vec(),
812                headers,
813                spooled_path: None,
814                spooled_len: None,
815            });
816
817            pos = data_end;
818        }
819
820        Ok(parts)
821    }
822
823    /// Parse any newly-available parts from a streamed multipart buffer.
824    ///
825    /// This method mutates `buffer` by draining bytes that were fully consumed.
826    /// It can be called repeatedly as new bytes arrive.
827    ///
828    /// - Set `eof = false` while more chunks may still arrive.
829    /// - Set `eof = true` on the final call to enforce that the stream ended on
830    ///   a valid multipart boundary.
831    #[allow(clippy::too_many_lines)]
832    pub fn parse_incremental(
833        &self,
834        buffer: &mut Vec<u8>,
835        state: &mut MultipartStreamState,
836        eof: bool,
837    ) -> Result<Vec<Part>, MultipartError> {
838        let mut parsed = Vec::new();
839
840        loop {
841            if state.done {
842                return Ok(parsed);
843            }
844
845            if !state.started {
846                match self.find_boundary_from(buffer, 0) {
847                    Ok(boundary_pos) => {
848                        state.started = true;
849                        if boundary_pos > 0 {
850                            buffer.drain(..boundary_pos);
851                        }
852                    }
853                    Err(MultipartError::UnexpectedEof) => {
854                        if eof {
855                            return Err(MultipartError::UnexpectedEof);
856                        }
857                        // Keep only the suffix that could still contain a split boundary.
858                        let keep = self.boundary.len().saturating_add(4);
859                        if buffer.len() > keep {
860                            let drain_to = buffer.len() - keep;
861                            buffer.drain(..drain_to);
862                        }
863                        return Ok(parsed);
864                    }
865                    Err(err) => return Err(err),
866                }
867            }
868
869            if state.current_part.is_none() {
870                if !buffer.starts_with(&self.boundary) {
871                    match self.find_boundary_from(buffer, 0) {
872                        Ok(boundary_pos) => {
873                            if boundary_pos > 0 {
874                                buffer.drain(..boundary_pos);
875                            }
876                        }
877                        Err(MultipartError::UnexpectedEof) => {
878                            if eof {
879                                return Err(MultipartError::UnexpectedEof);
880                            }
881                            return Ok(parsed);
882                        }
883                        Err(err) => return Err(err),
884                    }
885                }
886
887                let boundary_end = self.boundary.len();
888                if boundary_end + 2 > buffer.len() {
889                    if eof {
890                        return Err(MultipartError::UnexpectedEof);
891                    }
892                    return Ok(parsed);
893                }
894
895                let boundary_suffix = &buffer[boundary_end..boundary_end + 2];
896                if boundary_suffix == b"--" {
897                    state.done = true;
898
899                    // Consume through final boundary marker (+ optional CRLF).
900                    let mut consumed = boundary_end + 2;
901                    if consumed + 2 <= buffer.len() && buffer[consumed..consumed + 2] == *b"\r\n" {
902                        consumed += 2;
903                    }
904                    buffer.drain(..consumed);
905                    return Ok(parsed);
906                }
907
908                if boundary_suffix != b"\r\n" {
909                    return Err(MultipartError::InvalidFormat {
910                        detail: "expected CRLF after boundary",
911                    });
912                }
913
914                let headers_start = boundary_end + 2;
915                let (headers, data_start) = match self.parse_part_headers(buffer, headers_start) {
916                    Ok(v) => v,
917                    Err(MultipartError::UnexpectedEof) => {
918                        if eof {
919                            return Err(MultipartError::UnexpectedEof);
920                        }
921                        return Ok(parsed);
922                    }
923                    Err(err) => return Err(err),
924                };
925
926                let content_disp = headers
927                    .get("content-disposition")
928                    .ok_or(MultipartError::MissingContentDisposition)?;
929                let (name, filename) = parse_content_disposition(content_disp)?;
930                let content_type = headers.get("content-type").cloned();
931
932                state.current_part = Some(StreamingPartState::new(
933                    name,
934                    filename,
935                    content_type,
936                    headers,
937                ));
938                buffer.drain(..data_start);
939                continue;
940            }
941
942            let data_end = match self.find_boundary_in_part_data(buffer, 0) {
943                Ok(pos) => Some(pos),
944                Err(MultipartError::UnexpectedEof) => None,
945                Err(err) => return Err(err),
946            };
947
948            if let Some(data_end) = data_end {
949                let write_end = if data_end >= 2 && buffer[data_end - 2..data_end] == *b"\r\n" {
950                    data_end - 2
951                } else {
952                    data_end
953                };
954                if write_end > 0 {
955                    let Some(part_state) = state.current_part.as_mut() else {
956                        return Err(MultipartError::InvalidFormat {
957                            detail: "missing current multipart part state",
958                        });
959                    };
960                    part_state.append(&buffer[..write_end], &self.config, &mut state.total_size)?;
961                }
962
963                state.part_count = state.part_count.saturating_add(1);
964                if state.part_count > self.config.max_fields {
965                    return Err(MultipartError::TooManyFields {
966                        count: state.part_count,
967                        max: self.config.max_fields,
968                    });
969                }
970
971                let Some(part_state) = state.current_part.take() else {
972                    return Err(MultipartError::InvalidFormat {
973                        detail: "missing current multipart part state",
974                    });
975                };
976                parsed.push(part_state.into_part());
977
978                // Keep the next boundary in-buffer for the next iteration.
979                buffer.drain(..data_end);
980                continue;
981            }
982
983            if eof {
984                return Err(MultipartError::UnexpectedEof);
985            }
986
987            // No complete boundary yet: flush the safe prefix into the current part storage.
988            let keep = self.boundary.len().saturating_add(4);
989            if buffer.len() > keep {
990                let flush_len = buffer.len() - keep;
991                let Some(part_state) = state.current_part.as_mut() else {
992                    return Err(MultipartError::InvalidFormat {
993                        detail: "missing current multipart part state",
994                    });
995                };
996                part_state.append(&buffer[..flush_len], &self.config, &mut state.total_size)?;
997                buffer.drain(..flush_len);
998            }
999            return Ok(parsed);
1000        }
1001    }
1002
1003    fn find_boundary_from(&self, data: &[u8], start: usize) -> Result<usize, MultipartError> {
1004        let boundary = &self.boundary;
1005        let boundary_len = boundary.len();
1006        if data.len() < boundary_len {
1007            return Err(MultipartError::UnexpectedEof);
1008        }
1009
1010        let end = data.len() - boundary_len + 1;
1011        for i in start..end {
1012            if !data[i..].starts_with(boundary) {
1013                continue;
1014            }
1015
1016            // Boundaries must occur at the start of the body or at the start of a CRLF-delimited
1017            // line, and must be followed by either CRLF (next part) or `--` (final boundary).
1018            if i != 0 && (i < 2 || data[i - 2..i] != *b"\r\n") {
1019                continue;
1020            }
1021
1022            let boundary_end = i + boundary_len;
1023            if boundary_end + 2 > data.len() {
1024                return Err(MultipartError::UnexpectedEof);
1025            }
1026            let suffix = &data[boundary_end..boundary_end + 2];
1027            if suffix != b"\r\n" && suffix != b"--" {
1028                continue;
1029            }
1030
1031            return Ok(i);
1032        }
1033
1034        Err(MultipartError::UnexpectedEof)
1035    }
1036
1037    fn find_boundary_in_part_data(
1038        &self,
1039        data: &[u8],
1040        start: usize,
1041    ) -> Result<usize, MultipartError> {
1042        let boundary = &self.boundary;
1043        let boundary_len = boundary.len();
1044        if data.len() < boundary_len + 2 {
1045            return Err(MultipartError::UnexpectedEof);
1046        }
1047
1048        let end = data.len() - boundary_len + 1;
1049        for i in start..end {
1050            if !data[i..].starts_with(boundary) {
1051                continue;
1052            }
1053
1054            // Inside part payloads, boundaries must be preceded by CRLF.
1055            if i < 2 || data[i - 2..i] != *b"\r\n" {
1056                continue;
1057            }
1058
1059            let boundary_end = i + boundary_len;
1060            if boundary_end + 2 > data.len() {
1061                return Err(MultipartError::UnexpectedEof);
1062            }
1063            let suffix = &data[boundary_end..boundary_end + 2];
1064            if suffix != b"\r\n" && suffix != b"--" {
1065                continue;
1066            }
1067
1068            return Ok(i);
1069        }
1070
1071        Err(MultipartError::UnexpectedEof)
1072    }
1073
1074    fn parse_part_headers(
1075        &self,
1076        data: &[u8],
1077        start: usize,
1078    ) -> Result<(HashMap<String, String>, usize), MultipartError> {
1079        let mut headers = HashMap::new();
1080        let mut pos = start;
1081
1082        loop {
1083            let line_end = find_crlf(data, pos)?;
1084            let line = &data[pos..line_end];
1085            if line.is_empty() {
1086                return Ok((headers, line_end + 2));
1087            }
1088
1089            let line_str =
1090                std::str::from_utf8(line).map_err(|_| MultipartError::InvalidPartHeaders {
1091                    detail: "invalid UTF-8 in header".to_string(),
1092                })?;
1093
1094            if let Some((name, value)) = line_str.split_once(':') {
1095                headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
1096            }
1097
1098            pos = line_end + 2;
1099        }
1100    }
1101}
1102
1103fn find_crlf(data: &[u8], start: usize) -> Result<usize, MultipartError> {
1104    if data.len() < 2 {
1105        return Err(MultipartError::UnexpectedEof);
1106    }
1107    let end = data.len() - 1;
1108    for i in start..end {
1109        if data[i..i + 2] == *b"\r\n" {
1110            return Ok(i);
1111        }
1112    }
1113    Err(MultipartError::UnexpectedEof)
1114}
1115
1116/// Parse Content-Disposition header value.
1117///
1118/// Format: `form-data; name=\"field\"; filename=\"file.txt\"`
1119fn parse_content_disposition(value: &str) -> Result<(String, Option<String>), MultipartError> {
1120    let mut name = None;
1121    let mut filename = None;
1122
1123    for part in value.split(';') {
1124        let part = part.trim();
1125        if part.eq_ignore_ascii_case("form-data") {
1126            continue;
1127        }
1128
1129        if let Some((key, raw_value)) = part.split_once('=') {
1130            let key = key.trim();
1131            let value = raw_value.trim();
1132            if key.eq_ignore_ascii_case("name") {
1133                name = Some(unquote(value));
1134            } else if key.eq_ignore_ascii_case("filename") {
1135                let unquoted = unquote(value);
1136                if unquoted.contains("..")
1137                    || unquoted.contains('/')
1138                    || unquoted.contains('\\')
1139                    || unquoted.contains('\0')
1140                {
1141                    return Err(MultipartError::InvalidContentDisposition {
1142                        detail: "filename contains path traversal characters".to_string(),
1143                    });
1144                }
1145                filename = Some(unquoted);
1146            }
1147        }
1148    }
1149
1150    let name = name.ok_or_else(|| MultipartError::InvalidContentDisposition {
1151        detail: "missing name parameter".to_string(),
1152    })?;
1153
1154    Ok((name, filename))
1155}
1156
1157fn unquote(s: &str) -> String {
1158    let s = s.trim();
1159    if s.len() >= 2
1160        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
1161    {
1162        s[1..s.len() - 1].to_string()
1163    } else {
1164        s.to_string()
1165    }
1166}
1167
1168/// Parsed multipart form data.
1169#[derive(Debug)]
1170pub struct MultipartForm {
1171    parts: Vec<Part>,
1172    spool_threshold: usize,
1173}
1174
1175impl Default for MultipartForm {
1176    fn default() -> Self {
1177        Self::new()
1178    }
1179}
1180
1181impl MultipartForm {
1182    /// Create a new empty form.
1183    #[must_use]
1184    pub fn new() -> Self {
1185        Self {
1186            parts: Vec::new(),
1187            spool_threshold: DEFAULT_SPOOL_THRESHOLD,
1188        }
1189    }
1190
1191    /// Create from parsed parts.
1192    #[must_use]
1193    pub fn from_parts(parts: Vec<Part>) -> Self {
1194        Self {
1195            parts,
1196            spool_threshold: DEFAULT_SPOOL_THRESHOLD,
1197        }
1198    }
1199
1200    /// Create from parsed parts with a custom file spool threshold.
1201    #[must_use]
1202    pub fn from_parts_with_spool_threshold(parts: Vec<Part>, spool_threshold: usize) -> Self {
1203        Self {
1204            parts,
1205            spool_threshold,
1206        }
1207    }
1208
1209    /// Get all parts.
1210    #[must_use]
1211    pub fn parts(&self) -> &[Part] {
1212        &self.parts
1213    }
1214
1215    /// Consume the form and return all parsed parts.
1216    #[must_use]
1217    pub fn into_parts(mut self) -> Vec<Part> {
1218        std::mem::take(&mut self.parts)
1219    }
1220
1221    /// Get a form field value by name.
1222    #[must_use]
1223    pub fn get_field(&self, name: &str) -> Option<&str> {
1224        self.parts
1225            .iter()
1226            .find(|p| p.name == name && p.filename.is_none())
1227            .and_then(|p| p.text())
1228    }
1229
1230    /// Get a file by field name.
1231    #[must_use]
1232    pub fn get_file(&self, name: &str) -> Option<UploadFile> {
1233        self.parts
1234            .iter()
1235            .find(|p| p.name == name && p.filename.is_some())
1236            .and_then(|part| Self::upload_from_borrowed_part(part, self.spool_threshold))
1237    }
1238
1239    /// Remove and return a file by field name without cloning part data.
1240    pub fn take_file(&mut self, name: &str) -> Option<UploadFile> {
1241        let index = self
1242            .parts
1243            .iter()
1244            .position(|p| p.name == name && p.filename.is_some())?;
1245        let part = self.parts.swap_remove(index);
1246        UploadFile::from_part_with_spool_threshold(part, self.spool_threshold)
1247    }
1248
1249    /// Get all files.
1250    #[must_use]
1251    pub fn files(&self) -> Vec<UploadFile> {
1252        self.parts
1253            .iter()
1254            .filter(|p| p.filename.is_some())
1255            .filter_map(|part| Self::upload_from_borrowed_part(part, self.spool_threshold))
1256            .collect()
1257    }
1258
1259    /// Consume the form and return all file uploads without cloning part data.
1260    #[must_use]
1261    pub fn into_files(mut self) -> Vec<UploadFile> {
1262        let spool_threshold = self.spool_threshold;
1263        std::mem::take(&mut self.parts)
1264            .into_iter()
1265            .filter_map(|part| UploadFile::from_part_with_spool_threshold(part, spool_threshold))
1266            .collect()
1267    }
1268
1269    /// Get all regular form fields as (name, value) pairs.
1270    #[must_use]
1271    pub fn fields(&self) -> Vec<(&str, &str)> {
1272        self.parts
1273            .iter()
1274            .filter(|p| p.filename.is_none())
1275            .filter_map(|p| Some((p.name.as_str(), p.text()?)))
1276            .collect()
1277    }
1278
1279    /// Get all values for a field name (for multiple file uploads).
1280    #[must_use]
1281    pub fn get_files(&self, name: &str) -> Vec<UploadFile> {
1282        self.parts
1283            .iter()
1284            .filter(|p| p.name == name && p.filename.is_some())
1285            .filter_map(|part| Self::upload_from_borrowed_part(part, self.spool_threshold))
1286            .collect()
1287    }
1288
1289    /// Check if a field exists.
1290    #[must_use]
1291    pub fn has_field(&self, name: &str) -> bool {
1292        self.parts.iter().any(|p| p.name == name)
1293    }
1294
1295    /// Get the number of parts.
1296    #[must_use]
1297    pub fn len(&self) -> usize {
1298        self.parts.len()
1299    }
1300
1301    /// Check if the form is empty.
1302    #[must_use]
1303    pub fn is_empty(&self) -> bool {
1304        self.parts.is_empty()
1305    }
1306
1307    fn upload_from_borrowed_part(part: &Part, spool_threshold: usize) -> Option<UploadFile> {
1308        let data = part.bytes().ok()?;
1309        let owned_part = Part {
1310            name: part.name.clone(),
1311            filename: part.filename.clone(),
1312            content_type: part.content_type.clone(),
1313            data,
1314            headers: part.headers.clone(),
1315            spooled_path: None,
1316            spooled_len: None,
1317        };
1318        UploadFile::from_part_with_spool_threshold(owned_part, spool_threshold)
1319    }
1320}
1321
1322impl Drop for MultipartForm {
1323    fn drop(&mut self) {
1324        for part in &self.parts {
1325            if let Some(path) = part.spooled_path() {
1326                let _ = std::fs::remove_file(path);
1327            }
1328        }
1329    }
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335
1336    #[test]
1337    fn test_parse_boundary() {
1338        let ct = "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW";
1339        let boundary = parse_boundary(ct).unwrap();
1340        assert_eq!(boundary, "----WebKitFormBoundary7MA4YWxkTrZu0gW");
1341    }
1342
1343    #[test]
1344    fn test_parse_boundary_quoted() {
1345        let ct = r#"multipart/form-data; boundary="simple-boundary""#;
1346        let boundary = parse_boundary(ct).unwrap();
1347        assert_eq!(boundary, "simple-boundary");
1348    }
1349
1350    #[test]
1351    fn test_parse_boundary_case_insensitive_param_name() {
1352        let ct = r#"multipart/form-data; Boundary="simple-boundary""#;
1353        let boundary = parse_boundary(ct).unwrap();
1354        assert_eq!(boundary, "simple-boundary");
1355    }
1356
1357    #[test]
1358    fn test_parse_boundary_missing() {
1359        let ct = "multipart/form-data";
1360        let result = parse_boundary(ct);
1361        assert!(matches!(result, Err(MultipartError::MissingBoundary)));
1362    }
1363
1364    #[test]
1365    fn test_parse_boundary_rejects_too_long_value() {
1366        let too_long = "a".repeat(MAX_BOUNDARY_LEN + 1);
1367        let ct = format!("multipart/form-data; boundary={too_long}");
1368        let result = parse_boundary(&ct);
1369        assert!(matches!(result, Err(MultipartError::InvalidBoundary)));
1370    }
1371
1372    #[test]
1373    fn test_parse_boundary_wrong_content_type() {
1374        let ct = "application/json";
1375        let result = parse_boundary(ct);
1376        assert!(matches!(result, Err(MultipartError::InvalidBoundary)));
1377    }
1378
1379    #[test]
1380    fn test_parse_content_disposition_case_insensitive_params() {
1381        let (name, filename) =
1382            parse_content_disposition("form-data; Name=\"field\"; FileName=\"upload.txt\"")
1383                .expect("content disposition should parse");
1384        assert_eq!(name, "field");
1385        assert_eq!(filename.as_deref(), Some("upload.txt"));
1386    }
1387
1388    #[test]
1389    fn test_parse_simple_form() {
1390        let boundary = "----boundary";
1391        let body = concat!(
1392            "------boundary\r\n",
1393            "Content-Disposition: form-data; name=\"field1\"\r\n",
1394            "\r\n",
1395            "value1\r\n",
1396            "------boundary\r\n",
1397            "Content-Disposition: form-data; name=\"field2\"\r\n",
1398            "\r\n",
1399            "value2\r\n",
1400            "------boundary--\r\n"
1401        );
1402
1403        let parser = MultipartParser::new(boundary, MultipartConfig::default());
1404        let parts = parser.parse(body.as_bytes()).unwrap();
1405
1406        assert_eq!(parts.len(), 2);
1407        assert_eq!(parts[0].name, "field1");
1408        assert_eq!(parts[0].text(), Some("value1"));
1409        assert!(parts[0].is_field());
1410
1411        assert_eq!(parts[1].name, "field2");
1412        assert_eq!(parts[1].text(), Some("value2"));
1413    }
1414
1415    #[test]
1416    fn test_parse_simple_form_with_mixed_case_disposition_params() {
1417        let boundary = "----boundary";
1418        let body = concat!(
1419            "------boundary\r\n",
1420            "Content-Disposition: form-data; Name=\"field1\"\r\n",
1421            "\r\n",
1422            "value1\r\n",
1423            "------boundary\r\n",
1424            "Content-Disposition: form-data; Name=\"file\"; FileName=\"note.txt\"\r\n",
1425            "Content-Type: text/plain\r\n",
1426            "\r\n",
1427            "hello\r\n",
1428            "------boundary--\r\n"
1429        );
1430
1431        let parser = MultipartParser::new(boundary, MultipartConfig::default());
1432        let parts = parser.parse(body.as_bytes()).expect("multipart parse");
1433
1434        assert_eq!(parts.len(), 2);
1435        assert_eq!(parts[0].name, "field1");
1436        assert_eq!(parts[0].text(), Some("value1"));
1437        assert_eq!(parts[1].name, "file");
1438        assert_eq!(parts[1].filename.as_deref(), Some("note.txt"));
1439        assert_eq!(parts[1].text(), Some("hello"));
1440    }
1441
1442    #[test]
1443    fn test_parse_file_upload() {
1444        let boundary = "----boundary";
1445        let body = concat!(
1446            "------boundary\r\n",
1447            "Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n",
1448            "Content-Type: text/plain\r\n",
1449            "\r\n",
1450            "Hello, World!\r\n",
1451            "------boundary--\r\n"
1452        );
1453
1454        let parser = MultipartParser::new(boundary, MultipartConfig::default());
1455        let parts = parser.parse(body.as_bytes()).unwrap();
1456
1457        assert_eq!(parts.len(), 1);
1458        assert_eq!(parts[0].name, "file");
1459        assert_eq!(parts[0].filename, Some("test.txt".to_string()));
1460        assert_eq!(parts[0].content_type, Some("text/plain".to_string()));
1461        assert_eq!(parts[0].text(), Some("Hello, World!"));
1462        assert!(parts[0].is_file());
1463    }
1464
1465    #[test]
1466    fn test_parse_mixed_form() {
1467        let boundary = "----boundary";
1468        let body = concat!(
1469            "------boundary\r\n",
1470            "Content-Disposition: form-data; name=\"description\"\r\n",
1471            "\r\n",
1472            "A test file\r\n",
1473            "------boundary\r\n",
1474            "Content-Disposition: form-data; name=\"file\"; filename=\"data.bin\"\r\n",
1475            "Content-Type: application/octet-stream\r\n",
1476            "\r\n",
1477            "\x00\x01\x02\x03\r\n",
1478            "------boundary--\r\n"
1479        );
1480
1481        let parser = MultipartParser::new(boundary, MultipartConfig::default());
1482        let parts = parser.parse(body.as_bytes()).unwrap();
1483
1484        assert_eq!(parts.len(), 2);
1485
1486        assert_eq!(parts[0].name, "description");
1487        assert!(parts[0].is_field());
1488        assert_eq!(parts[0].text(), Some("A test file"));
1489
1490        assert_eq!(parts[1].name, "file");
1491        assert!(parts[1].is_file());
1492        assert_eq!(parts[1].data, vec![0x00, 0x01, 0x02, 0x03]);
1493    }
1494
1495    #[test]
1496    fn test_multipart_form_helpers() {
1497        let boundary = "----boundary";
1498        let body = concat!(
1499            "------boundary\r\n",
1500            "Content-Disposition: form-data; name=\"name\"\r\n",
1501            "\r\n",
1502            "John\r\n",
1503            "------boundary\r\n",
1504            "Content-Disposition: form-data; name=\"avatar\"; filename=\"photo.jpg\"\r\n",
1505            "Content-Type: image/jpeg\r\n",
1506            "\r\n",
1507            "JPEG DATA\r\n",
1508            "------boundary--\r\n"
1509        );
1510
1511        let parser = MultipartParser::new(boundary, MultipartConfig::default());
1512        let parts = parser.parse(body.as_bytes()).unwrap();
1513        let form = MultipartForm::from_parts(parts);
1514
1515        assert_eq!(form.get_field("name"), Some("John"));
1516        assert!(form.has_field("avatar"));
1517        assert_eq!(form.files().len(), 1);
1518        let f = form.get_file("avatar").unwrap();
1519        assert_eq!(f.filename, "photo.jpg");
1520        assert_eq!(f.content_type, "image/jpeg");
1521    }
1522
1523    #[test]
1524    fn test_multipart_form_take_file_and_into_files_move_data() {
1525        let parts = vec![
1526            Part {
1527                name: "note".to_string(),
1528                filename: None,
1529                content_type: None,
1530                data: b"hi".to_vec(),
1531                headers: HashMap::new(),
1532                spooled_path: None,
1533                spooled_len: None,
1534            },
1535            Part {
1536                name: "avatar".to_string(),
1537                filename: Some("a.bin".to_string()),
1538                content_type: Some("application/octet-stream".to_string()),
1539                data: vec![1, 2, 3, 4],
1540                headers: HashMap::new(),
1541                spooled_path: None,
1542                spooled_len: None,
1543            },
1544            Part {
1545                name: "avatar".to_string(),
1546                filename: Some("b.bin".to_string()),
1547                content_type: Some("application/octet-stream".to_string()),
1548                data: vec![9; 32],
1549                headers: HashMap::new(),
1550                spooled_path: None,
1551                spooled_len: None,
1552            },
1553        ];
1554
1555        let mut form = MultipartForm::from_parts_with_spool_threshold(parts, 8);
1556        let first = form.take_file("avatar").expect("first avatar file");
1557        assert_eq!(first.filename, "a.bin");
1558        assert_eq!(form.get_field("note"), Some("hi"));
1559        assert_eq!(form.get_files("avatar").len(), 1);
1560
1561        let files = form.into_files();
1562        assert_eq!(files.len(), 1);
1563        assert_eq!(files[0].filename, "b.bin");
1564        assert!(
1565            files[0].is_spooled(),
1566            "remaining file should respect custom spool threshold"
1567        );
1568    }
1569
1570    #[test]
1571    fn test_multipart_form_respects_custom_spool_threshold() {
1572        let part = Part {
1573            name: "avatar".to_string(),
1574            filename: Some("photo.jpg".to_string()),
1575            content_type: Some("image/jpeg".to_string()),
1576            data: vec![0xAB; 64],
1577            headers: HashMap::new(),
1578            spooled_path: None,
1579            spooled_len: None,
1580        };
1581
1582        let form = MultipartForm::from_parts_with_spool_threshold(vec![part], 1);
1583        let mut file = form.get_file("avatar").expect("avatar file");
1584        assert!(file.is_spooled(), "custom threshold should force spooling");
1585
1586        let spooled_path = file
1587            .spooled_path()
1588            .expect("spooled file path")
1589            .to_path_buf();
1590        assert!(spooled_path.exists(), "spooled file should exist");
1591
1592        futures_executor::block_on(file.close()).expect("close upload");
1593        assert!(!spooled_path.exists(), "spooled file should be removed");
1594    }
1595
1596    #[test]
1597    fn test_boundary_like_sequence_in_part_body_does_not_terminate_part() {
1598        // Ensure we do not treat an in-body "------boundaryX" as a boundary delimiter.
1599        let boundary = "----boundary";
1600        let body = concat!(
1601            "------boundary\r\n",
1602            "Content-Disposition: form-data; name=\"file\"; filename=\"data.bin\"\r\n",
1603            "Content-Type: application/octet-stream\r\n",
1604            "\r\n",
1605            "line1\r\n",
1606            "------boundaryX\r\n",
1607            "line2\r\n",
1608            "------boundary--\r\n"
1609        );
1610
1611        let parser = MultipartParser::new(boundary, MultipartConfig::default());
1612        let parts = parser.parse(body.as_bytes()).unwrap();
1613
1614        assert_eq!(parts.len(), 1);
1615        assert_eq!(parts[0].name, "file");
1616        assert!(parts[0].is_file());
1617        assert_eq!(parts[0].data, b"line1\r\n------boundaryX\r\nline2".to_vec());
1618    }
1619
1620    #[test]
1621    fn test_upload_file_async_read_seek_write() {
1622        let part = Part {
1623            name: "file".to_string(),
1624            filename: Some("note.txt".to_string()),
1625            content_type: Some("text/plain".to_string()),
1626            data: b"hello".to_vec(),
1627            headers: HashMap::new(),
1628            spooled_path: None,
1629            spooled_len: None,
1630        };
1631
1632        let mut file = UploadFile::from_part(part).expect("expected file");
1633        assert!(!file.is_spooled());
1634
1635        let first = futures_executor::block_on(file.read(Some(2))).expect("read prefix");
1636        assert_eq!(first, b"he".to_vec());
1637
1638        futures_executor::block_on(file.seek(SeekFrom::Start(0))).expect("seek start");
1639        futures_executor::block_on(file.write(b"Y")).expect("overwrite first byte");
1640        futures_executor::block_on(file.seek(SeekFrom::Start(0))).expect("seek start");
1641        let all = futures_executor::block_on(file.read(None)).expect("read full file");
1642        assert_eq!(all, b"Yello".to_vec());
1643
1644        futures_executor::block_on(file.close()).expect("close upload");
1645        assert!(futures_executor::block_on(file.read(Some(1))).is_err());
1646    }
1647
1648    #[test]
1649    fn test_upload_file_spools_large_payload() {
1650        let payload_len = DEFAULT_SPOOL_THRESHOLD + 4096;
1651        let payload = vec![b'a'; payload_len];
1652        let part = Part {
1653            name: "file".to_string(),
1654            filename: Some("large.bin".to_string()),
1655            content_type: Some("application/octet-stream".to_string()),
1656            data: payload.clone(),
1657            headers: HashMap::new(),
1658            spooled_path: None,
1659            spooled_len: None,
1660        };
1661
1662        let mut file = UploadFile::from_part(part).expect("expected file");
1663        assert!(file.is_spooled());
1664        assert_eq!(file.size(), payload_len);
1665
1666        let spooled_path = file
1667            .spooled_path()
1668            .expect("spooled file path")
1669            .to_path_buf();
1670        assert!(spooled_path.exists());
1671
1672        let full = file.bytes().expect("read full bytes");
1673        assert_eq!(full.len(), payload_len);
1674        assert_eq!(full, payload);
1675
1676        let prefix = futures_executor::block_on(file.read(Some(8))).expect("read prefix");
1677        assert_eq!(prefix, b"aaaaaaaa".to_vec());
1678
1679        futures_executor::block_on(file.close()).expect("close upload");
1680        assert!(!spooled_path.exists());
1681    }
1682
1683    #[test]
1684    fn test_upload_file_seek_before_start_is_error() {
1685        let part = Part {
1686            name: "file".to_string(),
1687            filename: Some("note.txt".to_string()),
1688            content_type: Some("text/plain".to_string()),
1689            data: b"hello".to_vec(),
1690            headers: HashMap::new(),
1691            spooled_path: None,
1692            spooled_len: None,
1693        };
1694
1695        let mut file = UploadFile::from_part(part).expect("expected file");
1696        let err = futures_executor::block_on(file.seek(SeekFrom::Current(-10)))
1697            .expect_err("seek should fail");
1698        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1699    }
1700
1701    #[test]
1702    fn test_incremental_parse_with_chunked_input() {
1703        let boundary = "----boundary";
1704        let body = concat!(
1705            "------boundary\r\n",
1706            "Content-Disposition: form-data; name=\"field1\"\r\n",
1707            "\r\n",
1708            "value1\r\n",
1709            "------boundary\r\n",
1710            "Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n",
1711            "Content-Type: text/plain\r\n",
1712            "\r\n",
1713            "hello-stream\r\n",
1714            "------boundary--\r\n"
1715        )
1716        .as_bytes()
1717        .to_vec();
1718
1719        let parser = MultipartParser::new(boundary, MultipartConfig::default());
1720        let mut state = MultipartStreamState::default();
1721        let mut buffer = Vec::new();
1722        let mut parts = Vec::new();
1723
1724        for chunk in body.chunks(5) {
1725            buffer.extend_from_slice(chunk);
1726            let mut parsed = parser
1727                .parse_incremental(&mut buffer, &mut state, false)
1728                .expect("incremental parse");
1729            parts.append(&mut parsed);
1730        }
1731
1732        let mut tail = parser
1733            .parse_incremental(&mut buffer, &mut state, true)
1734            .expect("final parse");
1735        parts.append(&mut tail);
1736
1737        assert!(state.is_done());
1738        assert_eq!(parts.len(), 2);
1739        assert_eq!(parts[0].name, "field1");
1740        assert_eq!(parts[0].text(), Some("value1"));
1741        assert_eq!(parts[1].name, "file");
1742        assert_eq!(parts[1].filename.as_deref(), Some("test.txt"));
1743        assert_eq!(parts[1].data, b"hello-stream".to_vec());
1744        assert!(buffer.is_empty());
1745    }
1746
1747    #[test]
1748    fn test_incremental_parse_keeps_buffer_bounded_for_large_streamed_file() {
1749        let boundary = "----boundary";
1750        let payload = vec![b'x'; 256 * 1024];
1751
1752        let mut body = Vec::new();
1753        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
1754        body.extend_from_slice(
1755            b"Content-Disposition: form-data; name=\"file\"; filename=\"large.bin\"\r\n",
1756        );
1757        body.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
1758        body.extend_from_slice(b"\r\n");
1759        body.extend_from_slice(&payload);
1760        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
1761
1762        let parser =
1763            MultipartParser::new(boundary, MultipartConfig::default().spool_threshold(1024));
1764        let mut state = MultipartStreamState::default();
1765        let mut buffer = Vec::new();
1766        let mut parts = Vec::new();
1767        let mut max_buffer_len = 0usize;
1768
1769        for chunk in body.chunks(513) {
1770            buffer.extend_from_slice(chunk);
1771            let mut parsed = parser
1772                .parse_incremental(&mut buffer, &mut state, false)
1773                .expect("incremental parse");
1774            parts.append(&mut parsed);
1775            max_buffer_len = max_buffer_len.max(buffer.len());
1776        }
1777
1778        let mut tail = parser
1779            .parse_incremental(&mut buffer, &mut state, true)
1780            .expect("final parse");
1781        parts.append(&mut tail);
1782
1783        assert!(state.is_done());
1784        assert_eq!(parts.len(), 1);
1785        assert_eq!(parts[0].name, "file");
1786        assert_eq!(parts[0].filename.as_deref(), Some("large.bin"));
1787        assert!(parts[0].is_spooled());
1788        let spooled_path = parts[0].spooled_path().expect("spooled path").to_path_buf();
1789        assert!(parts[0].data.is_empty());
1790        assert_eq!(parts[0].bytes().expect("read spooled bytes"), payload);
1791        std::fs::remove_file(spooled_path).expect("cleanup spooled test file");
1792
1793        // During streamed parsing, parser buffer should stay bounded rather than
1794        // growing with the whole payload size.
1795        assert!(
1796            max_buffer_len < 8 * 1024,
1797            "incremental parser buffer grew too large: {max_buffer_len}"
1798        );
1799    }
1800
1801    #[test]
1802    fn test_multipart_form_drop_cleans_spooled_parts() {
1803        let boundary = "----boundary";
1804        let payload = vec![b'z'; 32 * 1024];
1805
1806        let mut body = Vec::new();
1807        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
1808        body.extend_from_slice(
1809            b"Content-Disposition: form-data; name=\"file\"; filename=\"drop.bin\"\r\n",
1810        );
1811        body.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
1812        body.extend_from_slice(b"\r\n");
1813        body.extend_from_slice(&payload);
1814        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
1815
1816        let parser =
1817            MultipartParser::new(boundary, MultipartConfig::default().spool_threshold(1024));
1818        let mut state = MultipartStreamState::default();
1819        let mut buffer = Vec::new();
1820        let mut parts = Vec::new();
1821        for chunk in body.chunks(257) {
1822            buffer.extend_from_slice(chunk);
1823            let mut parsed = parser
1824                .parse_incremental(&mut buffer, &mut state, false)
1825                .expect("incremental parse");
1826            parts.append(&mut parsed);
1827        }
1828        let mut tail = parser
1829            .parse_incremental(&mut buffer, &mut state, true)
1830            .expect("final parse");
1831        parts.append(&mut tail);
1832
1833        assert_eq!(parts.len(), 1);
1834        assert!(parts[0].is_spooled());
1835        let spooled_path = parts[0].spooled_path().expect("spooled path").to_path_buf();
1836        assert!(spooled_path.exists());
1837
1838        let form = MultipartForm::from_parts_with_spool_threshold(parts, 1024);
1839        drop(form);
1840
1841        assert!(
1842            !spooled_path.exists(),
1843            "dropping multipart form should clean spooled part file"
1844        );
1845    }
1846}