1use std::collections::HashMap;
31
32pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
34
35pub const DEFAULT_MAX_TOTAL_SIZE: usize = 50 * 1024 * 1024;
37
38pub const DEFAULT_MAX_FIELDS: usize = 100;
40
41#[derive(Debug, Clone)]
43pub struct MultipartConfig {
44 max_file_size: usize,
46 max_total_size: usize,
48 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 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 #[must_use]
71 pub fn max_file_size(mut self, size: usize) -> Self {
72 self.max_file_size = size;
73 self
74 }
75
76 #[must_use]
78 pub fn max_total_size(mut self, size: usize) -> Self {
79 self.max_total_size = size;
80 self
81 }
82
83 #[must_use]
85 pub fn max_fields(mut self, count: usize) -> Self {
86 self.max_fields = count;
87 self
88 }
89
90 #[must_use]
92 pub fn get_max_file_size(&self) -> usize {
93 self.max_file_size
94 }
95
96 #[must_use]
98 pub fn get_max_total_size(&self) -> usize {
99 self.max_total_size
100 }
101
102 #[must_use]
104 pub fn get_max_fields(&self) -> usize {
105 self.max_fields
106 }
107}
108
109#[derive(Debug)]
111pub enum MultipartError {
112 MissingBoundary,
114 InvalidBoundary,
116 FileTooLarge { size: usize, max: usize },
118 TotalTooLarge { size: usize, max: usize },
120 TooManyFields { count: usize, max: usize },
122 MissingContentDisposition,
124 InvalidContentDisposition { detail: String },
126 InvalidPartHeaders { detail: String },
128 UnexpectedEof,
130 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#[derive(Debug, Clone)]
170pub struct Part {
171 pub name: String,
173 pub filename: Option<String>,
175 pub content_type: Option<String>,
177 pub data: Vec<u8>,
179 pub headers: HashMap<String, String>,
181}
182
183impl Part {
184 #[must_use]
186 pub fn is_file(&self) -> bool {
187 self.filename.is_some()
188 }
189
190 #[must_use]
192 pub fn is_field(&self) -> bool {
193 self.filename.is_none()
194 }
195
196 #[must_use]
200 pub fn text(&self) -> Option<&str> {
201 std::str::from_utf8(&self.data).ok()
202 }
203
204 #[must_use]
206 pub fn size(&self) -> usize {
207 self.data.len()
208 }
209}
210
211#[derive(Debug, Clone)]
213pub struct UploadFile {
214 pub field_name: String,
216 pub filename: String,
218 pub content_type: String,
220 pub data: Vec<u8>,
222}
223
224impl UploadFile {
225 #[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 #[must_use]
243 pub fn size(&self) -> usize {
244 self.data.len()
245 }
246
247 #[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
257pub fn parse_boundary(content_type: &str) -> Result<String, MultipartError> {
261 let ct_lower = content_type.to_ascii_lowercase();
262
263 if !ct_lower.starts_with("multipart/form-data") {
265 return Err(MultipartError::InvalidBoundary);
266 }
267
268 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 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#[derive(Debug)]
291pub struct MultipartParser {
292 boundary: Vec<u8>,
293 config: MultipartConfig,
294}
295
296impl MultipartParser {
297 #[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 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 pos = self.find_boundary(body, pos)?;
316
317 loop {
318 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 let boundary_end = pos + self.boundary.len();
328 if boundary_end + 2 <= body.len() && body[boundary_end..boundary_end + 2] == *b"--" {
329 break;
331 }
332
333 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 let (headers, header_end) = self.parse_part_headers(body, pos)?;
347 pos = header_end;
348
349 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 let data_end = self.find_boundary_from(body, pos)?;
359
360 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 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 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 pos = data_end;
399 }
400
401 Ok(parts)
402 }
403
404 fn find_boundary(&self, data: &[u8], start: usize) -> Result<usize, MultipartError> {
406 self.find_boundary_from(data, start)
407 }
408
409 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 if data.len() < boundary_len {
416 return Err(MultipartError::UnexpectedEof);
417 }
418
419 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 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 let line_end = self.find_crlf(data, pos)?;
442 let line = &data[pos..line_end];
443
444 if line.is_empty() {
446 return Ok((headers, line_end + 2));
448 }
449
450 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; }
464 }
465
466 #[allow(clippy::unused_self)]
468 fn find_crlf(&self, data: &[u8], start: usize) -> Result<usize, MultipartError> {
469 if data.len() < 2 {
471 return Err(MultipartError::UnexpectedEof);
472 }
473
474 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
485fn 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 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
530fn unquote(s: &str) -> String {
532 let s = s.trim();
533 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#[derive(Debug, Clone, Default)]
545pub struct MultipartForm {
546 parts: Vec<Part>,
548}
549
550impl MultipartForm {
551 #[must_use]
553 pub fn new() -> Self {
554 Self { parts: Vec::new() }
555 }
556
557 #[must_use]
559 pub fn from_parts(parts: Vec<Part>) -> Self {
560 Self { parts }
561 }
562
563 #[must_use]
565 pub fn parts(&self) -> &[Part] {
566 &self.parts
567 }
568
569 #[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 #[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 #[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 #[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 #[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 #[must_use]
622 pub fn has_field(&self, name: &str) -> bool {
623 self.parts.iter().any(|p| p.name == name)
624 }
625
626 #[must_use]
628 pub fn len(&self) -> usize {
629 self.parts.len()
630 }
631
632 #[must_use]
634 pub fn is_empty(&self) -> bool {
635 self.parts.is_empty()
636 }
637}
638
639#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
648 fn test_parse_boundary() {
649 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 assert_eq!(parts[0].name, "description");
749 assert!(parts[0].is_field());
750 assert_eq!(parts[0].text(), Some("A test file"));
751
752 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}