1use 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
13pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
15
16pub const DEFAULT_MAX_TOTAL_SIZE: usize = 50 * 1024 * 1024;
18
19pub const DEFAULT_MAX_FIELDS: usize = 100;
21
22pub const DEFAULT_SPOOL_THRESHOLD: usize = 1024 * 1024;
24const MAX_BOUNDARY_LEN: usize = 70;
26
27#[derive(Debug, Clone)]
29pub struct MultipartConfig {
30 max_file_size: usize,
32 max_total_size: usize,
34 max_fields: usize,
36 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 #[must_use]
54 pub fn new() -> Self {
55 Self::default()
56 }
57
58 #[must_use]
60 pub fn max_file_size(mut self, size: usize) -> Self {
61 self.max_file_size = size;
62 self
63 }
64
65 #[must_use]
67 pub fn max_total_size(mut self, size: usize) -> Self {
68 self.max_total_size = size;
69 self
70 }
71
72 #[must_use]
74 pub fn max_fields(mut self, count: usize) -> Self {
75 self.max_fields = count;
76 self
77 }
78
79 #[must_use]
81 pub fn spool_threshold(mut self, size: usize) -> Self {
82 self.spool_threshold = size;
83 self
84 }
85
86 #[must_use]
88 pub fn get_max_file_size(&self) -> usize {
89 self.max_file_size
90 }
91
92 #[must_use]
94 pub fn get_max_total_size(&self) -> usize {
95 self.max_total_size
96 }
97
98 #[must_use]
100 pub fn get_max_fields(&self) -> usize {
101 self.max_fields
102 }
103
104 #[must_use]
106 pub fn get_spool_threshold(&self) -> usize {
107 self.spool_threshold
108 }
109}
110
111#[derive(Debug)]
113pub enum MultipartError {
114 MissingBoundary,
116 InvalidBoundary,
118 FileTooLarge { size: usize, max: usize },
120 TotalTooLarge { size: usize, max: usize },
122 TooManyFields { count: usize, max: usize },
124 MissingContentDisposition,
126 InvalidContentDisposition { detail: String },
128 InvalidPartHeaders { detail: String },
130 UnexpectedEof,
132 InvalidFormat { detail: &'static str },
134 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#[derive(Debug, Clone)]
173pub struct Part {
174 pub name: String,
176 pub filename: Option<String>,
178 pub content_type: Option<String>,
180 pub data: Vec<u8>,
182 pub headers: HashMap<String, String>,
184 spooled_path: Option<PathBuf>,
185 spooled_len: Option<usize>,
186}
187
188impl Part {
189 #[must_use]
191 pub fn is_file(&self) -> bool {
192 self.filename.is_some()
193 }
194
195 #[must_use]
197 pub fn is_field(&self) -> bool {
198 self.filename.is_none()
199 }
200
201 #[must_use]
205 pub fn text(&self) -> Option<&str> {
206 std::str::from_utf8(&self.data).ok()
207 }
208
209 #[must_use]
211 pub fn size(&self) -> usize {
212 self.spooled_len.unwrap_or(self.data.len())
213 }
214
215 #[must_use]
217 pub fn is_spooled(&self) -> bool {
218 self.spooled_path.is_some()
219 }
220
221 #[must_use]
223 pub fn spooled_path(&self) -> Option<&Path> {
224 self.spooled_path.as_deref()
225 }
226
227 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#[derive(Debug)]
245pub struct UploadFile {
246 pub field_name: String,
248 pub filename: String,
250 pub content_type: String,
252 storage: UploadStorage,
253 cursor: u64,
254 closed: bool,
255}
256
257impl UploadFile {
258 #[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 #[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 #[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 #[must_use]
322 pub fn is_spooled(&self) -> bool {
323 matches!(self.storage, UploadStorage::SpooledTempFile { .. })
324 }
325
326 #[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 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 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 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 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 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 #[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
545pub 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#[derive(Debug)]
575pub struct MultipartParser {
576 boundary: Vec<u8>,
577 config: MultipartConfig,
578}
579
580#[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 #[must_use]
728 pub fn is_done(&self) -> bool {
729 self.done
730 }
731}
732
733impl MultipartParser {
734 #[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 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 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 #[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 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 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 buffer.drain(..data_end);
980 continue;
981 }
982
983 if eof {
984 return Err(MultipartError::UnexpectedEof);
985 }
986
987 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 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 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
1116fn 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#[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 #[must_use]
1184 pub fn new() -> Self {
1185 Self {
1186 parts: Vec::new(),
1187 spool_threshold: DEFAULT_SPOOL_THRESHOLD,
1188 }
1189 }
1190
1191 #[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 #[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 #[must_use]
1211 pub fn parts(&self) -> &[Part] {
1212 &self.parts
1213 }
1214
1215 #[must_use]
1217 pub fn into_parts(mut self) -> Vec<Part> {
1218 std::mem::take(&mut self.parts)
1219 }
1220
1221 #[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 #[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 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 #[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 #[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 #[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 #[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 #[must_use]
1291 pub fn has_field(&self, name: &str) -> bool {
1292 self.parts.iter().any(|p| p.name == name)
1293 }
1294
1295 #[must_use]
1297 pub fn len(&self) -> usize {
1298 self.parts.len()
1299 }
1300
1301 #[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 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 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}