Skip to main content

fastapi_http/
multipart.rs

1//! Multipart form data parser.
2//!
3//! This module provides streaming parsing of `multipart/form-data` requests,
4//! which is the standard encoding for file uploads.
5//!
6//! # Format
7//!
8//! Multipart form data consists of multiple parts separated by a boundary string:
9//!
10//! ```text
11//! --boundary\r\n
12//! Content-Disposition: form-data; name="field1"\r\n
13//! \r\n
14//! value1\r\n
15//! --boundary\r\n
16//! Content-Disposition: form-data; name="file"; filename="example.txt"\r\n
17//! Content-Type: text/plain\r\n
18//! \r\n
19//! file contents...\r\n
20//! --boundary--\r\n
21//! ```
22//!
23//! # Features
24//!
25//! - Streaming parser that doesn't buffer entire body
26//! - Per-file and total size limits
27//! - Extracts filename, content-type, and field name
28//! - Memory-safe: enforces limits before allocating
29
30use std::collections::HashMap;
31
32/// Default maximum file size (10MB).
33pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
34
35/// Default maximum total upload size (50MB).
36pub const DEFAULT_MAX_TOTAL_SIZE: usize = 50 * 1024 * 1024;
37
38/// Default maximum number of fields.
39pub const DEFAULT_MAX_FIELDS: usize = 100;
40
41/// Configuration for multipart parsing.
42#[derive(Debug, Clone)]
43pub struct MultipartConfig {
44    /// Maximum size per file in bytes.
45    max_file_size: usize,
46    /// Maximum total upload size in bytes.
47    max_total_size: usize,
48    /// Maximum number of fields (including files).
49    max_fields: usize,
50}
51
52impl Default for MultipartConfig {
53    fn default() -> Self {
54        Self {
55            max_file_size: DEFAULT_MAX_FILE_SIZE,
56            max_total_size: DEFAULT_MAX_TOTAL_SIZE,
57            max_fields: DEFAULT_MAX_FIELDS,
58        }
59    }
60}
61
62impl MultipartConfig {
63    /// Create a new configuration with default settings.
64    #[must_use]
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Set the maximum file size.
70    #[must_use]
71    pub fn max_file_size(mut self, size: usize) -> Self {
72        self.max_file_size = size;
73        self
74    }
75
76    /// Set the maximum total upload size.
77    #[must_use]
78    pub fn max_total_size(mut self, size: usize) -> Self {
79        self.max_total_size = size;
80        self
81    }
82
83    /// Set the maximum number of fields.
84    #[must_use]
85    pub fn max_fields(mut self, count: usize) -> Self {
86        self.max_fields = count;
87        self
88    }
89
90    /// Get the maximum file size.
91    #[must_use]
92    pub fn get_max_file_size(&self) -> usize {
93        self.max_file_size
94    }
95
96    /// Get the maximum total upload size.
97    #[must_use]
98    pub fn get_max_total_size(&self) -> usize {
99        self.max_total_size
100    }
101
102    /// Get the maximum number of fields.
103    #[must_use]
104    pub fn get_max_fields(&self) -> usize {
105        self.max_fields
106    }
107}
108
109/// Errors that can occur during multipart parsing.
110#[derive(Debug)]
111pub enum MultipartError {
112    /// Missing boundary in Content-Type header.
113    MissingBoundary,
114    /// Invalid boundary format.
115    InvalidBoundary,
116    /// File size exceeds limit.
117    FileTooLarge { size: usize, max: usize },
118    /// Total upload size exceeds limit.
119    TotalTooLarge { size: usize, max: usize },
120    /// Too many fields.
121    TooManyFields { count: usize, max: usize },
122    /// Missing Content-Disposition header.
123    MissingContentDisposition,
124    /// Invalid Content-Disposition header.
125    InvalidContentDisposition { detail: String },
126    /// Invalid part headers.
127    InvalidPartHeaders { detail: String },
128    /// Unexpected end of input.
129    UnexpectedEof,
130    /// Invalid multipart format.
131    InvalidFormat { detail: &'static str },
132}
133
134impl std::fmt::Display for MultipartError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::MissingBoundary => write!(f, "missing boundary in multipart Content-Type"),
138            Self::InvalidBoundary => write!(f, "invalid multipart boundary"),
139            Self::FileTooLarge { size, max } => {
140                write!(f, "file too large: {size} bytes exceeds limit of {max}")
141            }
142            Self::TotalTooLarge { size, max } => {
143                write!(
144                    f,
145                    "total upload too large: {size} bytes exceeds limit of {max}"
146                )
147            }
148            Self::TooManyFields { count, max } => {
149                write!(f, "too many fields: {count} exceeds limit of {max}")
150            }
151            Self::MissingContentDisposition => {
152                write!(f, "missing Content-Disposition header in part")
153            }
154            Self::InvalidContentDisposition { detail } => {
155                write!(f, "invalid Content-Disposition: {detail}")
156            }
157            Self::InvalidPartHeaders { detail } => {
158                write!(f, "invalid part headers: {detail}")
159            }
160            Self::UnexpectedEof => write!(f, "unexpected end of multipart data"),
161            Self::InvalidFormat { detail } => write!(f, "invalid multipart format: {detail}"),
162        }
163    }
164}
165
166impl std::error::Error for MultipartError {}
167
168/// A parsed multipart form part.
169#[derive(Debug, Clone)]
170pub struct Part {
171    /// Field name from Content-Disposition.
172    pub name: String,
173    /// Filename from Content-Disposition (if present).
174    pub filename: Option<String>,
175    /// Content-Type of the part (if present).
176    pub content_type: Option<String>,
177    /// The part's content.
178    pub data: Vec<u8>,
179    /// Additional headers.
180    pub headers: HashMap<String, String>,
181}
182
183impl Part {
184    /// Returns true if this part is a file upload.
185    #[must_use]
186    pub fn is_file(&self) -> bool {
187        self.filename.is_some()
188    }
189
190    /// Returns true if this part is a regular form field.
191    #[must_use]
192    pub fn is_field(&self) -> bool {
193        self.filename.is_none()
194    }
195
196    /// Get the content as a UTF-8 string (for form fields).
197    ///
198    /// Returns `None` if the content is not valid UTF-8.
199    #[must_use]
200    pub fn text(&self) -> Option<&str> {
201        std::str::from_utf8(&self.data).ok()
202    }
203
204    /// Get the size of the data in bytes.
205    #[must_use]
206    pub fn size(&self) -> usize {
207        self.data.len()
208    }
209}
210
211/// An uploaded file with metadata.
212#[derive(Debug, Clone)]
213pub struct UploadFile {
214    /// The field name.
215    pub field_name: String,
216    /// The original filename.
217    pub filename: String,
218    /// Content-Type of the file.
219    pub content_type: String,
220    /// File contents.
221    pub data: Vec<u8>,
222}
223
224impl UploadFile {
225    /// Create a new UploadFile from a Part.
226    ///
227    /// Returns `None` if the part is not a file.
228    #[must_use]
229    pub fn from_part(part: Part) -> Option<Self> {
230        let filename = part.filename?;
231        Some(Self {
232            field_name: part.name,
233            filename,
234            content_type: part
235                .content_type
236                .unwrap_or_else(|| "application/octet-stream".to_string()),
237            data: part.data,
238        })
239    }
240
241    /// Get the file size in bytes.
242    #[must_use]
243    pub fn size(&self) -> usize {
244        self.data.len()
245    }
246
247    /// Get the file extension from the filename.
248    #[must_use]
249    pub fn extension(&self) -> Option<&str> {
250        self.filename
251            .rsplit('.')
252            .next()
253            .filter(|ext| !ext.is_empty() && *ext != self.filename)
254    }
255}
256
257/// Parse boundary from Content-Type header.
258///
259/// Content-Type format: `multipart/form-data; boundary=----WebKitFormBoundary...`
260pub fn parse_boundary(content_type: &str) -> Result<String, MultipartError> {
261    let ct_lower = content_type.to_ascii_lowercase();
262
263    // Verify it's multipart/form-data
264    if !ct_lower.starts_with("multipart/form-data") {
265        return Err(MultipartError::InvalidBoundary);
266    }
267
268    // Find boundary parameter
269    for part in content_type.split(';') {
270        let part = part.trim();
271        if let Some(boundary) = part
272            .strip_prefix("boundary=")
273            .or_else(|| part.strip_prefix("BOUNDARY="))
274        {
275            // Remove quotes if present
276            let boundary = boundary.trim_matches('"').trim_matches('\'');
277            if boundary.is_empty() {
278                return Err(MultipartError::InvalidBoundary);
279            }
280            return Ok(boundary.to_string());
281        }
282    }
283
284    Err(MultipartError::MissingBoundary)
285}
286
287/// Streaming multipart parser.
288///
289/// Parses multipart data incrementally without buffering the entire body.
290#[derive(Debug)]
291pub struct MultipartParser {
292    boundary: Vec<u8>,
293    config: MultipartConfig,
294}
295
296impl MultipartParser {
297    /// Create a new parser with the given boundary.
298    #[must_use]
299    pub fn new(boundary: &str, config: MultipartConfig) -> Self {
300        Self {
301            boundary: format!("--{boundary}").into_bytes(),
302            config,
303        }
304    }
305
306    /// Parse all parts from the body.
307    ///
308    /// This reads the entire body and returns all parts.
309    pub fn parse(&self, body: &[u8]) -> Result<Vec<Part>, MultipartError> {
310        let mut parts = Vec::new();
311        let mut total_size = 0usize;
312        let mut pos = 0;
313
314        // Skip preamble and find first boundary
315        pos = self.find_boundary(body, pos)?;
316
317        loop {
318            // Check field limit
319            if parts.len() >= self.config.max_fields {
320                return Err(MultipartError::TooManyFields {
321                    count: parts.len() + 1,
322                    max: self.config.max_fields,
323                });
324            }
325
326            // Check if this is the final boundary (--boundary--)
327            let boundary_end = pos + self.boundary.len();
328            if boundary_end + 2 <= body.len() && body[boundary_end..boundary_end + 2] == *b"--" {
329                // End of multipart data
330                break;
331            }
332
333            // Skip boundary and CRLF
334            pos = boundary_end;
335            if pos + 2 > body.len() {
336                return Err(MultipartError::UnexpectedEof);
337            }
338            if body[pos..pos + 2] != *b"\r\n" {
339                return Err(MultipartError::InvalidFormat {
340                    detail: "expected CRLF after boundary",
341                });
342            }
343            pos += 2;
344
345            // Parse part headers
346            let (headers, header_end) = self.parse_part_headers(body, pos)?;
347            pos = header_end;
348
349            // Extract Content-Disposition
350            let content_disp = headers
351                .get("content-disposition")
352                .ok_or(MultipartError::MissingContentDisposition)?;
353
354            let (name, filename) = parse_content_disposition(content_disp)?;
355            let content_type = headers.get("content-type").cloned();
356
357            // Find next boundary to get data
358            let data_end = self.find_boundary_from(body, pos)?;
359
360            // Data ends at position before \r\n--boundary
361            let data = if data_end >= 2 && body[data_end - 2..data_end] == *b"\r\n" {
362                &body[pos..data_end - 2]
363            } else {
364                &body[pos..data_end]
365            };
366
367            // Check size limits
368            if filename.is_some() && data.len() > self.config.max_file_size {
369                return Err(MultipartError::FileTooLarge {
370                    size: data.len(),
371                    max: self.config.max_file_size,
372                });
373            }
374
375            total_size += data.len();
376            if total_size > self.config.max_total_size {
377                return Err(MultipartError::TotalTooLarge {
378                    size: total_size,
379                    max: self.config.max_total_size,
380                });
381            }
382
383            // Convert headers to HashMap<String, String>
384            let mut headers_map = HashMap::new();
385            for (k, v) in headers {
386                headers_map.insert(k, v);
387            }
388
389            parts.push(Part {
390                name,
391                filename,
392                content_type,
393                data: data.to_vec(),
394                headers: headers_map,
395            });
396
397            // Move to the boundary position
398            pos = data_end;
399        }
400
401        Ok(parts)
402    }
403
404    /// Find the position of the next boundary.
405    fn find_boundary(&self, data: &[u8], start: usize) -> Result<usize, MultipartError> {
406        self.find_boundary_from(data, start)
407    }
408
409    /// Find boundary starting from position.
410    fn find_boundary_from(&self, data: &[u8], start: usize) -> Result<usize, MultipartError> {
411        let boundary = &self.boundary;
412        let boundary_len = boundary.len();
413
414        // Must have enough data for at least one boundary check
415        if data.len() < boundary_len {
416            return Err(MultipartError::UnexpectedEof);
417        }
418
419        // Search up to and including the last position where boundary could fit
420        let end = data.len() - boundary_len + 1;
421        for i in start..end {
422            if data[i..].starts_with(boundary) {
423                return Ok(i);
424            }
425        }
426
427        Err(MultipartError::UnexpectedEof)
428    }
429
430    /// Parse headers from a part, returning headers and position after headers.
431    fn parse_part_headers(
432        &self,
433        data: &[u8],
434        start: usize,
435    ) -> Result<(HashMap<String, String>, usize), MultipartError> {
436        let mut headers = HashMap::new();
437        let mut pos = start;
438
439        loop {
440            // Find end of line
441            let line_end = self.find_crlf(data, pos)?;
442            let line = &data[pos..line_end];
443
444            // Empty line marks end of headers
445            if line.is_empty() {
446                // Skip the CRLF after empty line
447                return Ok((headers, line_end + 2));
448            }
449
450            // Parse header line
451            let line_str =
452                std::str::from_utf8(line).map_err(|_| MultipartError::InvalidPartHeaders {
453                    detail: "invalid UTF-8 in header".to_string(),
454                })?;
455
456            if let Some((name, value)) = line_str.split_once(':') {
457                let name = name.trim().to_ascii_lowercase();
458                let value = value.trim().to_string();
459                headers.insert(name, value);
460            }
461
462            pos = line_end + 2; // Skip CRLF
463        }
464    }
465
466    /// Find CRLF in data starting from position.
467    #[allow(clippy::unused_self)]
468    fn find_crlf(&self, data: &[u8], start: usize) -> Result<usize, MultipartError> {
469        // Need at least 2 bytes for CRLF
470        if data.len() < 2 {
471            return Err(MultipartError::UnexpectedEof);
472        }
473
474        // Search up to and including the last position where CRLF could fit
475        let end = data.len() - 1;
476        for i in start..end {
477            if data[i..i + 2] == *b"\r\n" {
478                return Ok(i);
479            }
480        }
481        Err(MultipartError::UnexpectedEof)
482    }
483}
484
485/// Parse Content-Disposition header value.
486///
487/// Format: `form-data; name="field"; filename="file.txt"`
488fn parse_content_disposition(value: &str) -> Result<(String, Option<String>), MultipartError> {
489    let mut name = None;
490    let mut filename = None;
491
492    for part in value.split(';') {
493        let part = part.trim();
494
495        if part.eq_ignore_ascii_case("form-data") {
496            continue;
497        }
498
499        if let Some(n) = part
500            .strip_prefix("name=")
501            .or_else(|| part.strip_prefix("NAME="))
502        {
503            name = Some(unquote(n));
504        } else if let Some(f) = part
505            .strip_prefix("filename=")
506            .or_else(|| part.strip_prefix("FILENAME="))
507        {
508            let unquoted = unquote(f);
509            // Validate filename to prevent path traversal attacks
510            if unquoted.contains("..")
511                || unquoted.contains('/')
512                || unquoted.contains('\\')
513                || unquoted.contains('\0')
514            {
515                return Err(MultipartError::InvalidContentDisposition {
516                    detail: "filename contains path traversal characters".to_string(),
517                });
518            }
519            filename = Some(unquoted);
520        }
521    }
522
523    let name = name.ok_or_else(|| MultipartError::InvalidContentDisposition {
524        detail: "missing name parameter".to_string(),
525    })?;
526
527    Ok((name, filename))
528}
529
530/// Remove quotes from a string.
531fn unquote(s: &str) -> String {
532    let s = s.trim();
533    // Need at least 2 characters for paired quotes
534    if s.len() >= 2
535        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
536    {
537        s[1..s.len() - 1].to_string()
538    } else {
539        s.to_string()
540    }
541}
542
543/// Parsed multipart form data.
544#[derive(Debug, Clone, Default)]
545pub struct MultipartForm {
546    /// All parsed parts.
547    parts: Vec<Part>,
548}
549
550impl MultipartForm {
551    /// Create a new empty form.
552    #[must_use]
553    pub fn new() -> Self {
554        Self { parts: Vec::new() }
555    }
556
557    /// Create from parsed parts.
558    #[must_use]
559    pub fn from_parts(parts: Vec<Part>) -> Self {
560        Self { parts }
561    }
562
563    /// Get all parts.
564    #[must_use]
565    pub fn parts(&self) -> &[Part] {
566        &self.parts
567    }
568
569    /// Get a form field value by name.
570    #[must_use]
571    pub fn get_field(&self, name: &str) -> Option<&str> {
572        self.parts
573            .iter()
574            .find(|p| p.name == name && p.filename.is_none())
575            .and_then(|p| p.text())
576    }
577
578    /// Get a file by field name.
579    #[must_use]
580    pub fn get_file(&self, name: &str) -> Option<UploadFile> {
581        self.parts
582            .iter()
583            .find(|p| p.name == name && p.filename.is_some())
584            .cloned()
585            .and_then(UploadFile::from_part)
586    }
587
588    /// Get all files.
589    #[must_use]
590    pub fn files(&self) -> Vec<UploadFile> {
591        self.parts
592            .iter()
593            .filter(|p| p.filename.is_some())
594            .cloned()
595            .filter_map(UploadFile::from_part)
596            .collect()
597    }
598
599    /// Get all regular form fields as (name, value) pairs.
600    #[must_use]
601    pub fn fields(&self) -> Vec<(&str, &str)> {
602        self.parts
603            .iter()
604            .filter(|p| p.filename.is_none())
605            .filter_map(|p| Some((p.name.as_str(), p.text()?)))
606            .collect()
607    }
608
609    /// Get all values for a field name (for multiple file uploads).
610    #[must_use]
611    pub fn get_files(&self, name: &str) -> Vec<UploadFile> {
612        self.parts
613            .iter()
614            .filter(|p| p.name == name && p.filename.is_some())
615            .cloned()
616            .filter_map(UploadFile::from_part)
617            .collect()
618    }
619
620    /// Check if a field exists.
621    #[must_use]
622    pub fn has_field(&self, name: &str) -> bool {
623        self.parts.iter().any(|p| p.name == name)
624    }
625
626    /// Get the number of parts.
627    #[must_use]
628    pub fn len(&self) -> usize {
629        self.parts.len()
630    }
631
632    /// Check if the form is empty.
633    #[must_use]
634    pub fn is_empty(&self) -> bool {
635        self.parts.is_empty()
636    }
637}
638
639// ============================================================================
640// Tests
641// ============================================================================
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn test_parse_boundary() {
649        // Standard boundary
650        let ct = "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW";
651        let boundary = parse_boundary(ct).unwrap();
652        assert_eq!(boundary, "----WebKitFormBoundary7MA4YWxkTrZu0gW");
653    }
654
655    #[test]
656    fn test_parse_boundary_quoted() {
657        let ct = r#"multipart/form-data; boundary="simple-boundary""#;
658        let boundary = parse_boundary(ct).unwrap();
659        assert_eq!(boundary, "simple-boundary");
660    }
661
662    #[test]
663    fn test_parse_boundary_missing() {
664        let ct = "multipart/form-data";
665        let result = parse_boundary(ct);
666        assert!(matches!(result, Err(MultipartError::MissingBoundary)));
667    }
668
669    #[test]
670    fn test_parse_boundary_wrong_content_type() {
671        let ct = "application/json";
672        let result = parse_boundary(ct);
673        assert!(matches!(result, Err(MultipartError::InvalidBoundary)));
674    }
675
676    #[test]
677    fn test_parse_simple_form() {
678        let boundary = "----boundary";
679        let body = concat!(
680            "------boundary\r\n",
681            "Content-Disposition: form-data; name=\"field1\"\r\n",
682            "\r\n",
683            "value1\r\n",
684            "------boundary\r\n",
685            "Content-Disposition: form-data; name=\"field2\"\r\n",
686            "\r\n",
687            "value2\r\n",
688            "------boundary--\r\n"
689        );
690
691        let parser = MultipartParser::new(boundary, MultipartConfig::default());
692        let parts = parser.parse(body.as_bytes()).unwrap();
693
694        assert_eq!(parts.len(), 2);
695        assert_eq!(parts[0].name, "field1");
696        assert_eq!(parts[0].text(), Some("value1"));
697        assert!(parts[0].is_field());
698
699        assert_eq!(parts[1].name, "field2");
700        assert_eq!(parts[1].text(), Some("value2"));
701    }
702
703    #[test]
704    fn test_parse_file_upload() {
705        let boundary = "----boundary";
706        let body = concat!(
707            "------boundary\r\n",
708            "Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n",
709            "Content-Type: text/plain\r\n",
710            "\r\n",
711            "Hello, World!\r\n",
712            "------boundary--\r\n"
713        );
714
715        let parser = MultipartParser::new(boundary, MultipartConfig::default());
716        let parts = parser.parse(body.as_bytes()).unwrap();
717
718        assert_eq!(parts.len(), 1);
719        assert_eq!(parts[0].name, "file");
720        assert_eq!(parts[0].filename, Some("test.txt".to_string()));
721        assert_eq!(parts[0].content_type, Some("text/plain".to_string()));
722        assert_eq!(parts[0].text(), Some("Hello, World!"));
723        assert!(parts[0].is_file());
724    }
725
726    #[test]
727    fn test_parse_mixed_form() {
728        let boundary = "----boundary";
729        let body = concat!(
730            "------boundary\r\n",
731            "Content-Disposition: form-data; name=\"description\"\r\n",
732            "\r\n",
733            "A test file\r\n",
734            "------boundary\r\n",
735            "Content-Disposition: form-data; name=\"file\"; filename=\"data.bin\"\r\n",
736            "Content-Type: application/octet-stream\r\n",
737            "\r\n",
738            "\x00\x01\x02\x03\r\n",
739            "------boundary--\r\n"
740        );
741
742        let parser = MultipartParser::new(boundary, MultipartConfig::default());
743        let parts = parser.parse(body.as_bytes()).unwrap();
744
745        assert_eq!(parts.len(), 2);
746
747        // Field
748        assert_eq!(parts[0].name, "description");
749        assert!(parts[0].is_field());
750        assert_eq!(parts[0].text(), Some("A test file"));
751
752        // File
753        assert_eq!(parts[1].name, "file");
754        assert!(parts[1].is_file());
755        assert_eq!(parts[1].data, vec![0x00, 0x01, 0x02, 0x03]);
756    }
757
758    #[test]
759    fn test_multipart_form() {
760        let boundary = "----boundary";
761        let body = concat!(
762            "------boundary\r\n",
763            "Content-Disposition: form-data; name=\"name\"\r\n",
764            "\r\n",
765            "John\r\n",
766            "------boundary\r\n",
767            "Content-Disposition: form-data; name=\"avatar\"; filename=\"photo.jpg\"\r\n",
768            "Content-Type: image/jpeg\r\n",
769            "\r\n",
770            "JPEG DATA\r\n",
771            "------boundary--\r\n"
772        );
773
774        let parser = MultipartParser::new(boundary, MultipartConfig::default());
775        let parts = parser.parse(body.as_bytes()).unwrap();
776        let form = MultipartForm::from_parts(parts);
777
778        assert_eq!(form.get_field("name"), Some("John"));
779        assert!(form.has_field("avatar"));
780
781        let file = form.get_file("avatar").unwrap();
782        assert_eq!(file.filename, "photo.jpg");
783        assert_eq!(file.content_type, "image/jpeg");
784    }
785
786    #[test]
787    fn test_file_size_limit() {
788        let boundary = "----boundary";
789        let large_data = "x".repeat(1000);
790        let body = format!(
791            "------boundary\r\n\
792             Content-Disposition: form-data; name=\"file\"; filename=\"big.txt\"\r\n\
793             \r\n\
794             {}\r\n\
795             ------boundary--\r\n",
796            large_data
797        );
798
799        let config = MultipartConfig::default().max_file_size(100);
800        let parser = MultipartParser::new(boundary, config);
801        let result = parser.parse(body.as_bytes());
802
803        assert!(matches!(result, Err(MultipartError::FileTooLarge { .. })));
804    }
805
806    #[test]
807    fn test_total_size_limit() {
808        let boundary = "----boundary";
809        let data = "x".repeat(500);
810        let body = format!(
811            "------boundary\r\n\
812             Content-Disposition: form-data; name=\"f1\"; filename=\"a.txt\"\r\n\
813             \r\n\
814             {}\r\n\
815             ------boundary\r\n\
816             Content-Disposition: form-data; name=\"f2\"; filename=\"b.txt\"\r\n\
817             \r\n\
818             {}\r\n\
819             ------boundary--\r\n",
820            data, data
821        );
822
823        let config = MultipartConfig::default()
824            .max_file_size(1000)
825            .max_total_size(800);
826        let parser = MultipartParser::new(boundary, config);
827        let result = parser.parse(body.as_bytes());
828
829        assert!(matches!(result, Err(MultipartError::TotalTooLarge { .. })));
830    }
831
832    #[test]
833    fn test_field_count_limit() {
834        let boundary = "----boundary";
835        let mut body = String::new();
836        for i in 0..5 {
837            body.push_str(&format!(
838                "------boundary\r\n\
839                 Content-Disposition: form-data; name=\"field{}\"\r\n\
840                 \r\n\
841                 value{}\r\n",
842                i, i
843            ));
844        }
845        body.push_str("------boundary--\r\n");
846
847        let config = MultipartConfig::default().max_fields(3);
848        let parser = MultipartParser::new(boundary, config);
849        let result = parser.parse(body.as_bytes());
850
851        assert!(matches!(result, Err(MultipartError::TooManyFields { .. })));
852    }
853
854    #[test]
855    fn test_upload_file_extension() {
856        let file = UploadFile {
857            field_name: "doc".to_string(),
858            filename: "report.pdf".to_string(),
859            content_type: "application/pdf".to_string(),
860            data: vec![],
861        };
862        assert_eq!(file.extension(), Some("pdf"));
863
864        let no_ext = UploadFile {
865            field_name: "doc".to_string(),
866            filename: "README".to_string(),
867            content_type: "text/plain".to_string(),
868            data: vec![],
869        };
870        assert_eq!(no_ext.extension(), None);
871    }
872
873    #[test]
874    fn test_content_disposition_parsing() {
875        let (name, filename) =
876            parse_content_disposition(r#"form-data; name="field"; filename="test.txt""#).unwrap();
877        assert_eq!(name, "field");
878        assert_eq!(filename, Some("test.txt".to_string()));
879
880        let (name, filename) = parse_content_disposition("form-data; name=simple").unwrap();
881        assert_eq!(name, "simple");
882        assert_eq!(filename, None);
883    }
884
885    #[test]
886    fn test_multiple_files_same_name() {
887        let boundary = "----boundary";
888        let body = concat!(
889            "------boundary\r\n",
890            "Content-Disposition: form-data; name=\"files\"; filename=\"a.txt\"\r\n",
891            "\r\n",
892            "file a\r\n",
893            "------boundary\r\n",
894            "Content-Disposition: form-data; name=\"files\"; filename=\"b.txt\"\r\n",
895            "\r\n",
896            "file b\r\n",
897            "------boundary--\r\n"
898        );
899
900        let parser = MultipartParser::new(boundary, MultipartConfig::default());
901        let parts = parser.parse(body.as_bytes()).unwrap();
902        let form = MultipartForm::from_parts(parts);
903
904        let files = form.get_files("files");
905        assert_eq!(files.len(), 2);
906        assert_eq!(files[0].filename, "a.txt");
907        assert_eq!(files[1].filename, "b.txt");
908    }
909}