1use crate::error::{ApiError, Result};
24use crate::extract::FromRequest;
25use crate::request::Request;
26use crate::stream::StreamingBody;
27use bytes::Bytes;
28use futures_util::stream;
29use http::StatusCode;
30use std::error::Error as _;
31use std::path::Path;
32use tokio::io::AsyncWriteExt;
33
34pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
36
37pub const DEFAULT_MAX_FIELDS: usize = 100;
39
40pub struct Multipart {
59 fields: Vec<MultipartField>,
60 current_index: usize,
61}
62
63impl Multipart {
64 fn new(fields: Vec<MultipartField>) -> Self {
66 Self {
67 fields,
68 current_index: 0,
69 }
70 }
71
72 pub async fn next_field(&mut self) -> Result<Option<MultipartField>> {
74 if self.current_index >= self.fields.len() {
75 return Ok(None);
76 }
77 let field = self.fields.get(self.current_index).cloned();
78 self.current_index += 1;
79 Ok(field)
80 }
81
82 pub fn into_fields(self) -> Vec<MultipartField> {
84 self.fields
85 }
86
87 pub fn field_count(&self) -> usize {
89 self.fields.len()
90 }
91}
92
93pub struct StreamingMultipart {
100 inner: multer::Multipart<'static>,
101 config: MultipartConfig,
102 field_count: usize,
103}
104
105impl StreamingMultipart {
106 fn new(stream: StreamingBody, boundary: String, config: MultipartConfig) -> Self {
107 Self {
108 inner: multer::Multipart::new(stream, boundary),
109 config,
110 field_count: 0,
111 }
112 }
113
114 pub async fn next_field(&mut self) -> Result<Option<StreamingMultipartField<'static>>> {
118 let field = self.inner.next_field().await.map_err(map_multer_error)?;
119 let Some(field) = field else {
120 return Ok(None);
121 };
122
123 self.field_count += 1;
124 if self.field_count > self.config.max_fields {
125 return Err(ApiError::bad_request(format!(
126 "Multipart field count exceeded limit of {}",
127 self.config.max_fields
128 )));
129 }
130
131 validate_streaming_field(&field, &self.config)?;
132
133 Ok(Some(StreamingMultipartField::new(
134 field,
135 self.config.max_file_size,
136 )))
137 }
138
139 pub fn field_count(&self) -> usize {
141 self.field_count
142 }
143}
144
145impl FromRequest for StreamingMultipart {
146 async fn from_request(req: &mut Request) -> Result<Self> {
147 let content_type = req
148 .headers()
149 .get(http::header::CONTENT_TYPE)
150 .and_then(|v| v.to_str().ok())
151 .ok_or_else(|| ApiError::bad_request("Missing Content-Type header"))?;
152
153 if !content_type.starts_with("multipart/form-data") {
154 return Err(ApiError::bad_request(format!(
155 "Expected multipart/form-data, got: {}",
156 content_type
157 )));
158 }
159
160 let boundary = extract_boundary(content_type)
161 .ok_or_else(|| ApiError::bad_request("Missing boundary in Content-Type"))?;
162
163 let config = req
164 .state()
165 .get::<MultipartConfig>()
166 .cloned()
167 .unwrap_or_default();
168
169 let stream = request_body_stream(req, config.max_size)?;
170 Ok(Self::new(stream, boundary, config))
171 }
172}
173
174pub struct StreamingMultipartField<'a> {
179 inner: multer::Field<'a>,
180 max_file_size: usize,
181 bytes_read: usize,
182}
183
184impl<'a> StreamingMultipartField<'a> {
185 fn new(inner: multer::Field<'a>, max_file_size: usize) -> Self {
186 Self {
187 inner,
188 max_file_size,
189 bytes_read: 0,
190 }
191 }
192
193 pub fn name(&self) -> Option<&str> {
195 self.inner.name()
196 }
197
198 pub fn file_name(&self) -> Option<&str> {
200 self.inner.file_name()
201 }
202
203 pub fn content_type(&self) -> Option<&str> {
205 self.inner.content_type().map(|mime| mime.essence_str())
206 }
207
208 pub fn is_file(&self) -> bool {
210 self.file_name().is_some()
211 }
212
213 pub fn bytes_read(&self) -> usize {
215 self.bytes_read
216 }
217
218 pub async fn chunk(&mut self) -> Result<Option<Bytes>> {
220 let chunk = self.inner.chunk().await.map_err(map_multer_error)?;
221 let Some(chunk) = chunk else {
222 return Ok(None);
223 };
224
225 self.bytes_read += chunk.len();
226 if self.bytes_read > self.max_file_size {
227 return Err(file_size_limit_error(self.max_file_size));
228 }
229
230 Ok(Some(chunk))
231 }
232
233 pub async fn bytes(&mut self) -> Result<Bytes> {
235 let mut buffer = bytes::BytesMut::new();
236 while let Some(chunk) = self.chunk().await? {
237 buffer.extend_from_slice(&chunk);
238 }
239 Ok(buffer.freeze())
240 }
241
242 pub async fn text(&mut self) -> Result<String> {
244 String::from_utf8(self.bytes().await?.to_vec())
245 .map_err(|e| ApiError::bad_request(format!("Invalid UTF-8 in field: {}", e)))
246 }
247
248 pub async fn save_to(
250 &mut self,
251 dir: impl AsRef<Path>,
252 filename: Option<&str>,
253 ) -> Result<String> {
254 let dir = dir.as_ref();
255
256 tokio::fs::create_dir_all(dir)
257 .await
258 .map_err(|e| ApiError::internal(format!("Failed to create upload directory: {}", e)))?;
259
260 let final_filename = filename
261 .map(|value| value.to_string())
262 .or_else(|| self.file_name().map(|value| value.to_string()))
263 .ok_or_else(|| {
264 ApiError::bad_request("No filename provided and field has no filename")
265 })?;
266
267 let safe_filename = sanitize_filename(&final_filename);
268 let file_path = dir.join(&safe_filename);
269 self.save_as(&file_path).await?;
270
271 Ok(file_path.to_string_lossy().to_string())
272 }
273
274 pub async fn save_as(&mut self, path: impl AsRef<Path>) -> Result<()> {
276 let path = path.as_ref();
277
278 if let Some(parent) = path.parent() {
279 tokio::fs::create_dir_all(parent)
280 .await
281 .map_err(|e| ApiError::internal(format!("Failed to create directory: {}", e)))?;
282 }
283
284 let mut file = tokio::fs::File::create(path)
285 .await
286 .map_err(|e| ApiError::internal(format!("Failed to create file: {}", e)))?;
287
288 while let Some(chunk) = self.chunk().await? {
289 file.write_all(&chunk)
290 .await
291 .map_err(|e| ApiError::internal(format!("Failed to save file: {}", e)))?;
292 }
293
294 file.flush()
295 .await
296 .map_err(|e| ApiError::internal(format!("Failed to flush file: {}", e)))?;
297
298 Ok(())
299 }
300
301 pub async fn into_uploaded_file(mut self) -> Result<UploadedFile> {
303 let filename = self
304 .file_name()
305 .ok_or_else(|| ApiError::bad_request("Field is not a file upload"))?
306 .to_string();
307 let content_type = self.content_type().map(|value| value.to_string());
308 let data = self.bytes().await?;
309
310 Ok(UploadedFile {
311 filename,
312 content_type,
313 data,
314 })
315 }
316}
317
318#[derive(Clone)]
320pub struct MultipartField {
321 name: Option<String>,
322 file_name: Option<String>,
323 content_type: Option<String>,
324 data: Bytes,
325}
326
327impl MultipartField {
328 pub fn new(
330 name: Option<String>,
331 file_name: Option<String>,
332 content_type: Option<String>,
333 data: Bytes,
334 ) -> Self {
335 Self {
336 name,
337 file_name,
338 content_type,
339 data,
340 }
341 }
342
343 pub fn name(&self) -> Option<&str> {
345 self.name.as_deref()
346 }
347
348 pub fn file_name(&self) -> Option<&str> {
350 self.file_name.as_deref()
351 }
352
353 pub fn content_type(&self) -> Option<&str> {
355 self.content_type.as_deref()
356 }
357
358 pub fn is_file(&self) -> bool {
360 self.file_name.is_some()
361 }
362
363 pub async fn bytes(&self) -> Result<Bytes> {
365 Ok(self.data.clone())
366 }
367
368 pub async fn text(&self) -> Result<String> {
370 String::from_utf8(self.data.to_vec())
371 .map_err(|e| ApiError::bad_request(format!("Invalid UTF-8 in field: {}", e)))
372 }
373
374 pub fn size(&self) -> usize {
376 self.data.len()
377 }
378
379 pub async fn save_to(&self, dir: impl AsRef<Path>, filename: Option<&str>) -> Result<String> {
394 let dir = dir.as_ref();
395
396 tokio::fs::create_dir_all(dir)
398 .await
399 .map_err(|e| ApiError::internal(format!("Failed to create upload directory: {}", e)))?;
400
401 let final_filename = filename
403 .map(|s| s.to_string())
404 .or_else(|| self.file_name.clone())
405 .ok_or_else(|| {
406 ApiError::bad_request("No filename provided and field has no filename")
407 })?;
408
409 let safe_filename = sanitize_filename(&final_filename);
411 let file_path = dir.join(&safe_filename);
412
413 tokio::fs::write(&file_path, &self.data)
415 .await
416 .map_err(|e| ApiError::internal(format!("Failed to save file: {}", e)))?;
417
418 Ok(file_path.to_string_lossy().to_string())
419 }
420}
421
422fn sanitize_filename(filename: &str) -> String {
424 filename
426 .replace(['/', '\\'], "_")
427 .replace("..", "_")
428 .trim_start_matches('.')
429 .to_string()
430}
431
432impl FromRequest for Multipart {
433 async fn from_request(req: &mut Request) -> Result<Self> {
434 let content_type = req
436 .headers()
437 .get(http::header::CONTENT_TYPE)
438 .and_then(|v| v.to_str().ok())
439 .ok_or_else(|| ApiError::bad_request("Missing Content-Type header"))?;
440
441 if !content_type.starts_with("multipart/form-data") {
442 return Err(ApiError::bad_request(format!(
443 "Expected multipart/form-data, got: {}",
444 content_type
445 )));
446 }
447
448 let boundary = extract_boundary(content_type)
450 .ok_or_else(|| ApiError::bad_request("Missing boundary in Content-Type"))?;
451
452 req.load_body().await?;
454
455 let body = req
456 .take_body()
457 .ok_or_else(|| ApiError::internal("Body already consumed"))?;
458
459 let fields = parse_multipart(&body, &boundary)?;
461
462 Ok(Multipart::new(fields))
463 }
464}
465
466impl rustapi_openapi::OperationModifier for Multipart {
467 fn update_operation(op: &mut rustapi_openapi::Operation) {
468 use rustapi_openapi::{MediaType, RequestBody, SchemaRef};
469 use std::collections::BTreeMap;
470
471 let mut content = BTreeMap::new();
472 content.insert(
473 "multipart/form-data".to_string(),
474 MediaType {
475 schema: Some(SchemaRef::Inline(serde_json::json!({ "type": "object" }))),
476 example: None,
477 },
478 );
479
480 op.request_body = Some(RequestBody {
481 description: None,
482 required: Some(true),
483 content,
484 });
485 }
486}
487
488impl rustapi_openapi::OperationModifier for StreamingMultipart {
489 fn update_operation(op: &mut rustapi_openapi::Operation) {
490 Multipart::update_operation(op);
491 }
492}
493
494fn request_body_stream(req: &mut Request, limit: usize) -> Result<StreamingBody> {
495 if let Some(stream) = req.take_stream() {
496 return Ok(StreamingBody::new(stream, Some(limit)));
497 }
498
499 if let Some(body) = req.take_body() {
500 let stream = stream::once(async move { Ok::<Bytes, ApiError>(body) });
501 return Ok(StreamingBody::from_stream(stream, Some(limit)));
502 }
503
504 Err(ApiError::internal("Body already consumed"))
505}
506
507fn validate_streaming_field(field: &multer::Field<'_>, config: &MultipartConfig) -> Result<()> {
508 if field.file_name().is_none() || config.allowed_content_types.is_empty() {
509 return Ok(());
510 }
511
512 let content_type = field
513 .content_type()
514 .map(|mime| mime.essence_str().to_string())
515 .ok_or_else(|| ApiError::bad_request("Uploaded file is missing Content-Type"))?;
516
517 if config
518 .allowed_content_types
519 .iter()
520 .any(|allowed| allowed.eq_ignore_ascii_case(&content_type))
521 {
522 return Ok(());
523 }
524
525 Err(ApiError::bad_request(format!(
526 "Unsupported content type '{}'",
527 content_type
528 )))
529}
530
531fn file_size_limit_error(limit: usize) -> ApiError {
532 ApiError::new(
533 StatusCode::PAYLOAD_TOO_LARGE,
534 "payload_too_large",
535 format!("Multipart field exceeded limit of {} bytes", limit),
536 )
537}
538
539fn map_multer_error(error: multer::Error) -> ApiError {
540 if let Some(source) = error.source() {
541 if let Some(api_error) = source.downcast_ref::<ApiError>() {
542 return api_error.clone();
543 }
544 }
545
546 let message = error.to_string();
547 if message.to_ascii_lowercase().contains("size limit") {
548 return ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large", message);
549 }
550
551 ApiError::bad_request(format!("Invalid multipart body: {}", message))
552}
553
554fn extract_boundary(content_type: &str) -> Option<String> {
556 content_type.split(';').find_map(|part| {
557 let part = part.trim();
558 if part.starts_with("boundary=") {
559 let boundary = part.trim_start_matches("boundary=").trim_matches('"');
560 Some(boundary.to_string())
561 } else {
562 None
563 }
564 })
565}
566
567fn find_subsequence(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
568 haystack[from..]
569 .windows(needle.len())
570 .position(|window| window == needle)
571 .map(|pos| from + pos)
572}
573
574fn trim_trailing_crlf(mut data: Vec<u8>) -> Vec<u8> {
575 while data.ends_with(b"\r\n") {
576 data.truncate(data.len().saturating_sub(2));
577 }
578 while data.ends_with(b"\n") {
579 data.truncate(data.len().saturating_sub(1));
580 }
581 data
582}
583
584fn parse_multipart_part(part: &[u8]) -> Option<MultipartField> {
585 let (header_end, body_start) = if let Some(pos) = find_subsequence(part, b"\r\n\r\n", 0) {
586 (pos, pos + 4)
587 } else {
588 let pos = find_subsequence(part, b"\n\n", 0)?;
589 (pos, pos + 2)
590 };
591
592 let headers_section = String::from_utf8_lossy(&part[..header_end]);
593 let body_section = trim_trailing_crlf(part[body_start..].to_vec());
594
595 let mut name = None;
596 let mut filename = None;
597 let mut content_type = None;
598
599 for header_line in headers_section.lines() {
600 let header_line = header_line.trim();
601 if header_line.is_empty() {
602 continue;
603 }
604
605 if let Some((key, value)) = header_line.split_once(':') {
606 let key = key.trim().to_lowercase();
607 let value = value.trim();
608
609 match key.as_str() {
610 "content-disposition" => {
611 for segment in value.split(';') {
612 let segment = segment.trim();
613 if segment.starts_with("name=") {
614 name = Some(
615 segment
616 .trim_start_matches("name=")
617 .trim_matches('"')
618 .to_string(),
619 );
620 } else if segment.starts_with("filename=") {
621 filename = Some(
622 segment
623 .trim_start_matches("filename=")
624 .trim_matches('"')
625 .to_string(),
626 );
627 }
628 }
629 }
630 "content-type" => {
631 content_type = Some(value.to_string());
632 }
633 _ => {}
634 }
635 }
636 }
637
638 Some(MultipartField::new(
639 name,
640 filename,
641 content_type,
642 Bytes::from(body_section),
643 ))
644}
645
646fn parse_multipart(body: &Bytes, boundary: &str) -> Result<Vec<MultipartField>> {
648 let delimiter = format!("--{}", boundary);
649 let delim = delimiter.as_bytes();
650
651 let first = find_subsequence(body, delim, 0)
652 .ok_or_else(|| ApiError::bad_request("No multipart boundary found"))?;
653
654 let mut fields = Vec::new();
655 let mut cursor = first + delim.len();
656
657 if body[cursor..].starts_with(b"--") {
658 return Ok(fields);
659 }
660
661 if body[cursor..].starts_with(b"\r\n") {
662 cursor += 2;
663 } else if body.get(cursor) == Some(&b'\n') {
664 cursor += 1;
665 }
666
667 while cursor < body.len() {
668 let next = find_subsequence(body, delim, cursor);
669 let part_end = next.unwrap_or(body.len());
670 let part = &body[cursor..part_end];
671
672 if !part.is_empty() {
673 if let Some(field) = parse_multipart_part(part) {
674 fields.push(field);
675 }
676 }
677
678 let Some(next_pos) = next else {
679 break;
680 };
681
682 cursor = next_pos + delim.len();
683 if body[cursor..].starts_with(b"--") {
684 break;
685 }
686 if body[cursor..].starts_with(b"\r\n") {
687 cursor += 2;
688 } else if body.get(cursor) == Some(&b'\n') {
689 cursor += 1;
690 }
691 }
692
693 Ok(fields)
694}
695
696#[derive(Clone)]
698pub struct MultipartConfig {
699 pub max_size: usize,
701 pub max_fields: usize,
703 pub max_file_size: usize,
705 pub allowed_content_types: Vec<String>,
707}
708
709impl Default for MultipartConfig {
710 fn default() -> Self {
711 Self {
712 max_size: DEFAULT_MAX_FILE_SIZE,
713 max_fields: DEFAULT_MAX_FIELDS,
714 max_file_size: DEFAULT_MAX_FILE_SIZE,
715 allowed_content_types: Vec::new(),
716 }
717 }
718}
719
720impl MultipartConfig {
721 pub fn new() -> Self {
723 Self::default()
724 }
725
726 pub fn max_size(mut self, size: usize) -> Self {
728 self.max_size = size;
729 self
730 }
731
732 pub fn max_fields(mut self, count: usize) -> Self {
734 self.max_fields = count;
735 self
736 }
737
738 pub fn max_file_size(mut self, size: usize) -> Self {
740 self.max_file_size = size;
741 self
742 }
743
744 pub fn allowed_content_types(mut self, types: Vec<String>) -> Self {
746 self.allowed_content_types = types;
747 self
748 }
749
750 pub fn allow_content_type(mut self, content_type: impl Into<String>) -> Self {
752 self.allowed_content_types.push(content_type.into());
753 self
754 }
755}
756
757#[derive(Clone)]
759pub struct UploadedFile {
760 pub filename: String,
762 pub content_type: Option<String>,
764 pub data: Bytes,
766}
767
768impl UploadedFile {
769 pub fn from_field(field: &MultipartField) -> Option<Self> {
771 field.file_name().map(|filename| Self {
772 filename: filename.to_string(),
773 content_type: field.content_type().map(|s| s.to_string()),
774 data: field.data.clone(),
775 })
776 }
777
778 pub fn size(&self) -> usize {
780 self.data.len()
781 }
782
783 pub fn extension(&self) -> Option<&str> {
785 self.filename.rsplit('.').next()
786 }
787
788 pub async fn save_to(&self, dir: impl AsRef<Path>) -> Result<String> {
790 let dir = dir.as_ref();
791
792 tokio::fs::create_dir_all(dir)
793 .await
794 .map_err(|e| ApiError::internal(format!("Failed to create upload directory: {}", e)))?;
795
796 let safe_filename = sanitize_filename(&self.filename);
797 let file_path = dir.join(&safe_filename);
798
799 tokio::fs::write(&file_path, &self.data)
800 .await
801 .map_err(|e| ApiError::internal(format!("Failed to save file: {}", e)))?;
802
803 Ok(file_path.to_string_lossy().to_string())
804 }
805
806 pub async fn save_as(&self, path: impl AsRef<Path>) -> Result<()> {
808 let path = path.as_ref();
809
810 if let Some(parent) = path.parent() {
811 tokio::fs::create_dir_all(parent)
812 .await
813 .map_err(|e| ApiError::internal(format!("Failed to create directory: {}", e)))?;
814 }
815
816 tokio::fs::write(path, &self.data)
817 .await
818 .map_err(|e| ApiError::internal(format!("Failed to save file: {}", e)))?;
819
820 Ok(())
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827 use futures_util::stream;
828
829 fn chunked_body_stream(
830 body: Bytes,
831 chunk_size: usize,
832 ) -> impl futures_util::Stream<Item = Result<Bytes>> + Send + 'static {
833 let chunks = body
834 .chunks(chunk_size)
835 .map(Bytes::copy_from_slice)
836 .map(Ok)
837 .collect::<Vec<_>>();
838 stream::iter(chunks)
839 }
840
841 fn streaming_multipart_from_body(
842 body: Bytes,
843 boundary: &str,
844 config: MultipartConfig,
845 ) -> StreamingMultipart {
846 let stream =
847 StreamingBody::from_stream(chunked_body_stream(body, 7), Some(config.max_size));
848 StreamingMultipart::new(stream, boundary.to_string(), config)
849 }
850
851 #[test]
852 fn test_extract_boundary() {
853 let ct = "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW";
854 assert_eq!(
855 extract_boundary(ct),
856 Some("----WebKitFormBoundary7MA4YWxkTrZu0gW".to_string())
857 );
858
859 let ct_quoted = "multipart/form-data; boundary=\"----WebKitFormBoundary\"";
860 assert_eq!(
861 extract_boundary(ct_quoted),
862 Some("----WebKitFormBoundary".to_string())
863 );
864 }
865
866 #[test]
867 fn test_sanitize_filename() {
868 assert_eq!(sanitize_filename("test.txt"), "test.txt");
869 assert_eq!(sanitize_filename("../../../etc/passwd"), "______etc_passwd");
870 assert_eq!(
872 sanitize_filename("..\\..\\windows\\system32"),
873 "____windows_system32"
874 );
875 assert_eq!(sanitize_filename(".hidden"), "hidden");
876 }
877
878 #[test]
879 fn test_parse_simple_multipart() {
880 let boundary = "----WebKitFormBoundary";
881 let body = "------WebKitFormBoundary\r\n\
882 Content-Disposition: form-data; name=\"field1\"\r\n\
883 \r\n\
884 value1\r\n\
885 ------WebKitFormBoundary\r\n\
886 Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n\
887 Content-Type: text/plain\r\n\
888 \r\n\
889 file content\r\n\
890 ------WebKitFormBoundary--\r\n"
891 .to_string();
892
893 let fields = parse_multipart(&Bytes::from(body), boundary).unwrap();
894 assert_eq!(fields.len(), 2);
895
896 assert_eq!(fields[0].name(), Some("field1"));
897 assert!(!fields[0].is_file());
898
899 assert_eq!(fields[1].name(), Some("file"));
900 assert_eq!(fields[1].file_name(), Some("test.txt"));
901 assert_eq!(fields[1].content_type(), Some("text/plain"));
902 assert!(fields[1].is_file());
903 }
904
905 #[tokio::test]
906 async fn test_parse_multipart_preserves_binary_payload() {
907 let boundary = "test-boundary";
908 let binary = vec![0x4D, 0x5A, 0x90, 0x00, 0xFF, 0xFE, 0x00, 0x00];
909 let mut body = format!(
910 "--{boundary}\r\nContent-Disposition: form-data; name=\"project_name\"\r\n\r\napp\r\n\
911 --{boundary}\r\nContent-Disposition: form-data; name=\"binary\"; filename=\"app.bin\"\r\n\
912 Content-Type: application/octet-stream\r\n\r\n"
913 )
914 .into_bytes();
915 body.extend_from_slice(&binary);
916 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
917
918 let fields = parse_multipart(&Bytes::from(body), boundary).unwrap();
919 assert_eq!(fields.len(), 2);
920 assert_eq!(
921 fields[1].bytes().await.expect("binary field"),
922 binary.as_slice()
923 );
924 }
925
926 #[test]
927 fn test_multipart_config() {
928 let config = MultipartConfig::new()
929 .max_size(20 * 1024 * 1024)
930 .max_fields(50)
931 .max_file_size(5 * 1024 * 1024)
932 .allow_content_type("image/png")
933 .allow_content_type("image/jpeg");
934
935 assert_eq!(config.max_size, 20 * 1024 * 1024);
936 assert_eq!(config.max_fields, 50);
937 assert_eq!(config.max_file_size, 5 * 1024 * 1024);
938 assert_eq!(config.allowed_content_types.len(), 2);
939 }
940
941 #[tokio::test]
942 async fn streaming_multipart_reads_chunked_body() {
943 let boundary = "----RustApiBoundary";
944 let body = format!(
945 "--{boundary}\r\n\
946 Content-Disposition: form-data; name=\"title\"\r\n\
947 \r\n\
948 hello\r\n\
949 --{boundary}\r\n\
950 Content-Disposition: form-data; name=\"file\"; filename=\"demo.txt\"\r\n\
951 Content-Type: text/plain\r\n\
952 \r\n\
953 streamed-content\r\n\
954 --{boundary}--\r\n"
955 );
956
957 let mut multipart = streaming_multipart_from_body(
958 Bytes::from(body),
959 boundary,
960 MultipartConfig::new().max_size(1024).max_file_size(1024),
961 );
962
963 let mut title = multipart.next_field().await.unwrap().unwrap();
964 assert_eq!(title.name(), Some("title"));
965 assert_eq!(title.text().await.unwrap(), "hello");
966 drop(title);
967
968 let mut file = multipart.next_field().await.unwrap().unwrap();
969 assert_eq!(file.file_name(), Some("demo.txt"));
970 assert_eq!(file.content_type(), Some("text/plain"));
971 assert_eq!(file.bytes().await.unwrap(), Bytes::from("streamed-content"));
972 drop(file);
973
974 assert!(multipart.next_field().await.unwrap().is_none());
975 assert_eq!(multipart.field_count(), 2);
976 }
977
978 #[tokio::test]
979 async fn streaming_multipart_enforces_per_file_limit() {
980 let boundary = "----RustApiBoundary";
981 let body = format!(
982 "--{boundary}\r\n\
983 Content-Disposition: form-data; name=\"file\"; filename=\"demo.txt\"\r\n\
984 Content-Type: text/plain\r\n\
985 \r\n\
986 way-too-large\r\n\
987 --{boundary}--\r\n"
988 );
989
990 let mut multipart = streaming_multipart_from_body(
991 Bytes::from(body),
992 boundary,
993 MultipartConfig::new().max_size(1024).max_file_size(4),
994 );
995
996 let mut file = multipart.next_field().await.unwrap().unwrap();
997 let error = file.bytes().await.unwrap_err();
998 assert_eq!(error.status, StatusCode::PAYLOAD_TOO_LARGE);
999 assert!(error.message.contains("4"));
1000 }
1001
1002 #[tokio::test]
1003 async fn streaming_multipart_enforces_field_count_limit() {
1004 let boundary = "----RustApiBoundary";
1005 let body = format!(
1006 "--{boundary}\r\n\
1007 Content-Disposition: form-data; name=\"first\"\r\n\
1008 \r\n\
1009 one\r\n\
1010 --{boundary}\r\n\
1011 Content-Disposition: form-data; name=\"second\"\r\n\
1012 \r\n\
1013 two\r\n\
1014 --{boundary}--\r\n"
1015 );
1016
1017 let mut multipart = streaming_multipart_from_body(
1018 Bytes::from(body),
1019 boundary,
1020 MultipartConfig::new().max_size(1024).max_fields(1),
1021 );
1022
1023 assert!(multipart.next_field().await.unwrap().is_some());
1024 let next = multipart.next_field().await;
1025 assert!(next.is_err());
1026 let error = next.err().unwrap();
1027 assert_eq!(error.status, StatusCode::BAD_REQUEST);
1028 assert!(error.message.contains("field count exceeded"));
1029 }
1030
1031 #[tokio::test]
1032 async fn streaming_multipart_save_to_writes_incrementally() {
1033 let boundary = "----RustApiBoundary";
1034 let body = format!(
1035 "--{boundary}\r\n\
1036 Content-Disposition: form-data; name=\"file\"; filename=\"demo.txt\"\r\n\
1037 Content-Type: text/plain\r\n\
1038 \r\n\
1039 persisted\r\n\
1040 --{boundary}--\r\n"
1041 );
1042
1043 let mut multipart = streaming_multipart_from_body(
1044 Bytes::from(body),
1045 boundary,
1046 MultipartConfig::new().max_size(1024).max_file_size(1024),
1047 );
1048
1049 let mut file = multipart.next_field().await.unwrap().unwrap();
1050 let temp_dir =
1051 std::env::temp_dir().join(format!("rustapi-streaming-upload-{}", uuid::Uuid::new_v4()));
1052 let saved_path = file.save_to(&temp_dir, None).await.unwrap();
1053 let saved = tokio::fs::read_to_string(&saved_path).await.unwrap();
1054
1055 assert_eq!(saved, "persisted");
1056
1057 tokio::fs::remove_dir_all(&temp_dir).await.unwrap();
1058 }
1059}