1mod de;
39mod spool;
40
41pub use de::{FormDeserializer, FormError};
42pub use spool::{sanitize_file_name, set_spool_root, spool_root, SPOOL_FILE_PREFIX};
43
44use crate::error::{Error, Result};
45use crate::http::STREAM_BUF_SIZE;
46use bytes::Bytes;
47use std::fmt;
48use std::path::{Path, PathBuf};
49use std::pin::Pin;
50use std::sync::Arc;
51use std::task::{Context, Poll};
52use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
53
54tokio::task_local! {
55 static ACTIVE_FORM: Arc<MultipartForm>;
58}
59
60pub(crate) struct ActiveForm;
67
68impl ActiveForm {
69 pub(crate) fn run<R>(form: Arc<MultipartForm>, f: impl FnOnce() -> R) -> R {
71 ACTIVE_FORM.sync_scope(form, f)
72 }
73
74 pub(crate) fn resolve(position: usize) -> Option<FormFile> {
76 ACTIVE_FORM
77 .try_with(|form| form.files.get(position).cloned())
78 .ok()
79 .flatten()
80 }
81}
82
83#[derive(Clone)]
89pub struct FormFile {
90 inner: Arc<FormFileInner>,
91}
92
93struct FormFileInner {
94 field_name: String,
95 original_file_name: String,
96 file_name: String,
97 content_type: Option<String>,
98 size: u64,
99 source: FileSource,
100}
101
102enum FileSource {
103 Memory(Bytes),
106 Disk {
108 path: PathBuf,
109 _guard: tempfile::TempPath,
110 },
111}
112
113struct SharedBytesReader {
115 data: Bytes,
116 position: usize,
117}
118
119impl AsyncRead for SharedBytesReader {
120 fn poll_read(
121 mut self: Pin<&mut Self>,
122 _cx: &mut Context<'_>,
123 buf: &mut ReadBuf<'_>,
124 ) -> Poll<std::io::Result<()>> {
125 let position = self.position;
126 let remaining = &self.data[position..];
127 if remaining.is_empty() {
128 return Poll::Ready(Ok(()));
129 }
130 let take = remaining.len().min(buf.remaining());
131 buf.put_slice(&remaining[..take]);
132 self.position = position + take;
133 Poll::Ready(Ok(()))
134 }
135}
136
137impl fmt::Debug for FormFile {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 f.debug_struct("FormFile")
140 .field("field_name", &self.inner.field_name)
141 .field("file_name", &self.inner.file_name)
142 .field("content_type", &self.inner.content_type)
143 .field("size", &self.inner.size)
144 .field("spooled_to_disk", &matches!(self.inner.source, FileSource::Disk { .. }))
145 .finish()
146 }
147}
148
149impl FormFile {
150 pub fn field_name(&self) -> &str {
152 &self.inner.field_name
153 }
154
155 pub fn file_name(&self) -> &str {
161 &self.inner.file_name
162 }
163
164 pub fn original_file_name(&self) -> &str {
168 &self.inner.original_file_name
169 }
170
171 pub fn content_type(&self) -> Option<&str> {
176 self.inner.content_type.as_deref()
177 }
178
179 pub fn size(&self) -> u64 {
181 self.inner.size
182 }
183
184 pub fn is_empty(&self) -> bool {
186 self.inner.size == 0
187 }
188
189 pub fn extension(&self) -> Option<String> {
191 Path::new(&self.inner.file_name)
192 .extension()
193 .and_then(|e| e.to_str())
194 .map(|e| e.to_ascii_lowercase())
195 }
196
197 pub fn path(&self) -> Option<&Path> {
204 match &self.inner.source {
205 FileSource::Disk { path, .. } => Some(path.as_path()),
206 FileSource::Memory(_) => None,
207 }
208 }
209
210 pub async fn open(&self) -> Result<Box<dyn AsyncRead + Unpin + Send>> {
215 match &self.inner.source {
216 FileSource::Memory(bytes) => Ok(Box::new(SharedBytesReader {
217 data: Bytes::clone(bytes),
218 position: 0,
219 })),
220 FileSource::Disk { path, .. } => {
221 let file = tokio::fs::File::open(path).await.map_err(io_error)?;
222 Ok(Box::new(file))
223 }
224 }
225 }
226
227 pub async fn read_bytes(&self) -> Result<Vec<u8>> {
232 match &self.inner.source {
233 FileSource::Memory(bytes) => Ok(bytes.to_vec()),
234 FileSource::Disk { path, .. } => tokio::fs::read(path).await.map_err(io_error),
235 }
236 }
237
238 pub async fn copy_to<W>(&self, writer: &mut W) -> Result<u64>
243 where
244 W: AsyncWrite + Unpin + ?Sized,
245 {
246 let mut reader = self.open().await?;
247 let mut buffer = vec![0u8; STREAM_BUF_SIZE];
248 let mut total: u64 = 0;
249
250 loop {
251 let read = reader.read(&mut buffer).await.map_err(io_error)?;
252 if read == 0 {
253 return Ok(total);
254 }
255 writer
256 .write_all(&buffer[..read])
257 .await
258 .map_err(io_error)?;
259 total += read as u64;
260 }
261 }
262
263 pub async fn save_as(&self, destination: impl AsRef<Path>) -> Result<()> {
268 let destination = destination.as_ref();
269 let parent = destination
270 .parent()
271 .filter(|p| !p.as_os_str().is_empty())
272 .unwrap_or_else(|| Path::new("."));
273 tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
274
275 let staging = staging_path(destination);
276 let mut out = tokio::fs::File::create(&staging).await.map_err(io_error)?;
277
278 if let Err(err) = self.copy_to(&mut out).await {
279 drop(out);
280 let _ = tokio::fs::remove_file(&staging).await;
281 return Err(err);
282 }
283 out.flush().await.map_err(io_error)?;
284 out.sync_all().await.map_err(io_error)?;
285 drop(out);
286
287 if let Err(err) = tokio::fs::rename(&staging, destination).await {
288 let _ = tokio::fs::remove_file(&staging).await;
289 return Err(io_error(err));
290 }
291 Ok(())
292 }
293
294 fn from_parts(
295 field_name: String,
296 original_file_name: String,
297 content_type: Option<String>,
298 size: u64,
299 source: FileSource,
300 ) -> Self {
301 let file_name = sanitize_file_name(&original_file_name);
302 Self {
303 inner: Arc::new(FormFileInner {
304 field_name,
305 original_file_name,
306 file_name,
307 content_type,
308 size,
309 source,
310 }),
311 }
312 }
313}
314
315fn staging_path(destination: &Path) -> PathBuf {
320 let parent = destination
321 .parent()
322 .filter(|p| !p.as_os_str().is_empty())
323 .unwrap_or_else(|| Path::new("."));
324 let name = destination
325 .file_name()
326 .and_then(|n| n.to_str())
327 .unwrap_or("upload");
328 let nonce = std::time::SystemTime::now()
329 .duration_since(std::time::UNIX_EPOCH)
330 .map(|d| d.as_nanos())
331 .unwrap_or(0);
332 parent.join(format!(".{}.{}.{}.part", name, std::process::id(), nonce))
333}
334
335fn io_error(err: std::io::Error) -> Error {
336 Error::Internal(format!("file I/O error: {err}"))
337}
338
339pub struct FormFileBuilder {
345 field_name: String,
346 original_file_name: String,
347 content_type: Option<String>,
348 memory_threshold: usize,
349 max_size: u64,
350 spool_dir: PathBuf,
351 buffer: Vec<u8>,
352 spooled: Option<SpooledState>,
353 written: u64,
354}
355
356struct SpooledState {
357 writer: tokio::io::BufWriter<tokio::fs::File>,
361 guard: tempfile::TempPath,
362 path: PathBuf,
363}
364
365impl FormFileBuilder {
366 pub fn new(
371 field_name: impl Into<String>,
372 raw_file_name: impl Into<String>,
373 content_type: Option<String>,
374 ) -> Self {
375 Self {
376 field_name: field_name.into(),
377 original_file_name: raw_file_name.into(),
378 content_type,
379 memory_threshold: 1024 * 1024,
380 max_size: 128 * 1024 * 1024,
381 spool_dir: spool_root(),
382 buffer: Vec::new(),
383 spooled: None,
384 written: 0,
385 }
386 }
387
388 pub fn memory_threshold(mut self, bytes: usize) -> Self {
390 self.memory_threshold = bytes;
391 self
392 }
393
394 pub fn max_size(mut self, bytes: u64) -> Self {
398 self.max_size = bytes;
399 self
400 }
401
402 pub fn spool_dir(mut self, dir: impl Into<PathBuf>) -> Self {
404 self.spool_dir = dir.into();
405 self
406 }
407
408 pub async fn write(&mut self, chunk: &[u8]) -> Result<()> {
414 if chunk.is_empty() {
415 return Ok(());
416 }
417 self.written = self.written.saturating_add(chunk.len() as u64);
418 if self.written > self.max_size {
419 return Err(Error::PayloadTooLarge(format!(
420 "file field `{}` exceeds the {}-byte limit",
421 self.field_name, self.max_size
422 )));
423 }
424
425 if self.spooled.is_none() && self.buffer.len() + chunk.len() > self.memory_threshold {
426 self.spill().await?;
427 }
428
429 match &mut self.spooled {
430 Some(state) => {
431 state.writer.write_all(chunk).await.map_err(io_error)?;
432 }
433 None => self.buffer.extend_from_slice(chunk),
434 }
435 Ok(())
436 }
437
438 pub async fn finish(mut self) -> Result<FormFile> {
440 if let Some(state) = self.spooled.take() {
441 let SpooledState {
442 mut writer,
443 guard,
444 path,
445 } = state;
446 writer.flush().await.map_err(io_error)?;
449 let file = writer.into_inner();
450 file.sync_all().await.map_err(io_error)?;
451 drop(file);
452 Ok(FormFile::from_parts(
453 self.field_name,
454 self.original_file_name,
455 self.content_type,
456 self.written,
457 FileSource::Disk {
458 path,
459 _guard: guard,
460 },
461 ))
462 } else {
463 Ok(FormFile::from_parts(
464 self.field_name,
465 self.original_file_name,
466 self.content_type,
467 self.written,
468 FileSource::Memory(Bytes::from(std::mem::take(&mut self.buffer))),
469 ))
470 }
471 }
472
473 async fn spill(&mut self) -> Result<()> {
474 tokio::fs::create_dir_all(&self.spool_dir)
475 .await
476 .map_err(io_error)?;
477 let named = tempfile::Builder::new()
478 .prefix(SPOOL_FILE_PREFIX)
479 .tempfile_in(&self.spool_dir)
480 .map_err(io_error)?;
481 let (std_file, guard) = named.into_parts();
482 let path = guard.to_path_buf();
483 let mut writer = tokio::io::BufWriter::with_capacity(
484 STREAM_BUF_SIZE,
485 tokio::fs::File::from_std(std_file),
486 );
487 if !self.buffer.is_empty() {
488 writer.write_all(&self.buffer).await.map_err(io_error)?;
489 }
490 self.buffer = Vec::new();
491 self.buffer.shrink_to_fit();
492 self.spooled = Some(SpooledState {
493 writer,
494 guard,
495 path,
496 });
497 Ok(())
498 }
499}
500
501#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct FormField {
504 pub name: String,
506 pub value: String,
508}
509
510#[derive(Debug, Clone, Default)]
514pub struct MultipartForm {
515 fields: Vec<FormField>,
516 files: Vec<FormFile>,
517}
518
519impl MultipartForm {
520 pub fn new() -> Self {
522 Self::default()
523 }
524
525 pub fn push_field(&mut self, name: impl Into<String>, value: impl Into<String>) {
527 let name = name.into();
528 if name.is_empty() {
529 return;
530 }
531 self.fields.push(FormField {
532 name,
533 value: value.into(),
534 });
535 }
536
537 pub fn push_file(&mut self, file: FormFile) {
539 self.files.push(file);
540 }
541
542 pub fn fields(&self) -> &[FormField] {
544 &self.fields
545 }
546
547 pub fn files(&self) -> &[FormFile] {
549 &self.files
550 }
551
552 pub fn text<'a>(&'a self, name: &str) -> Option<&'a str> {
554 self.texts(name).next()
555 }
556
557 pub fn texts<'a, 'n>(&'a self, name: &'n str) -> impl Iterator<Item = &'a str> + 'n
559 where
560 'a: 'n,
561 {
562 self.fields
563 .iter()
564 .filter(move |f| f.name == name)
565 .map(|f| f.value.as_str())
566 }
567
568 pub fn file<'a>(&'a self, name: &str) -> Option<&'a FormFile> {
570 self.files_named(name).next()
571 }
572
573 pub fn files_named<'a, 'n>(&'a self, name: &'n str) -> impl Iterator<Item = &'a FormFile> + 'n
575 where
576 'a: 'n,
577 {
578 self.files.iter().filter(move |f| f.field_name() == name)
579 }
580
581 pub fn contains(&self, name: &str) -> bool {
583 self.fields.iter().any(|f| f.name == name) || self.file(name).is_some()
584 }
585
586 pub fn is_empty(&self) -> bool {
588 self.fields.is_empty() && self.files.is_empty()
589 }
590
591 pub fn field_count(&self) -> usize {
593 self.fields.len()
594 }
595
596 pub fn file_count(&self) -> usize {
598 self.files.len()
599 }
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605
606 #[test]
607 fn sanitize_strips_path_traversal() {
608 assert_eq!(sanitize_file_name("../../etc/passwd"), "passwd");
609 assert_eq!(sanitize_file_name(r"..\..\windows\win.ini"), "win.ini");
610 assert_eq!(sanitize_file_name("/absolute/path/a.txt"), "a.txt");
611 }
612
613 #[test]
614 fn sanitize_rejects_empty_and_dot_names() {
615 assert_eq!(sanitize_file_name(""), "file");
616 assert_eq!(sanitize_file_name(" "), "file");
617 assert_eq!(sanitize_file_name(".."), "file");
618 assert_eq!(sanitize_file_name("."), "file");
619 assert_eq!(sanitize_file_name("trailing."), "trailing");
620 }
621
622 #[test]
623 fn sanitize_replaces_reserved_characters() {
624 assert_eq!(sanitize_file_name("a:b|c?.txt"), "a_b_c_.txt");
625 assert_eq!(sanitize_file_name("nul\u{0}byte.txt"), "nulbyte.txt");
626 }
627
628 #[test]
629 fn sanitize_keeps_leading_dot_names() {
630 assert_eq!(sanitize_file_name(".env"), ".env");
631 }
632
633 #[test]
634 fn sanitize_caps_length() {
635 let long = "x".repeat(500) + ".txt";
636 let out = sanitize_file_name(&long);
637 assert!(out.len() <= 200, "got {} bytes", out.len());
638 assert!(out.ends_with(".txt"));
639 }
640
641 #[tokio::test]
642 async fn builder_keeps_small_files_in_memory() {
643 let mut builder = FormFileBuilder::new("avatar", "a.png", Some("image/png".into()))
644 .memory_threshold(1024);
645 builder.write(b"hello").await.unwrap();
646 let file = builder.finish().await.unwrap();
647 assert_eq!(file.size(), 5);
648 assert!(file.path().is_none());
649 assert_eq!(file.read_bytes().await.unwrap(), b"hello");
650 assert_eq!(file.content_type(), Some("image/png"));
651 assert_eq!(file.extension().as_deref(), Some("png"));
652 }
653
654 #[tokio::test]
655 async fn builder_spills_large_files_to_disk_and_cleans_up() {
656 let dir = tempfile::tempdir().unwrap();
657 let mut builder = FormFileBuilder::new("blob", "big.bin", None)
658 .memory_threshold(8)
659 .spool_dir(dir.path().to_path_buf());
660 builder.write(b"0123456789").await.unwrap();
661 builder.write(b"abcdefghij").await.unwrap();
662 let file = builder.finish().await.unwrap();
663
664 assert_eq!(file.size(), 20);
665 let path = file.path().expect("should be spooled").to_path_buf();
666 assert!(path.exists());
667 assert_eq!(file.read_bytes().await.unwrap(), b"0123456789abcdefghij");
668
669 drop(file);
670 assert!(!path.exists(), "spooled file must be deleted on drop");
671 }
672
673 #[tokio::test]
674 async fn builder_enforces_max_size() {
675 let mut builder = FormFileBuilder::new("f", "f.bin", None).max_size(4);
676 let err = builder.write(b"12345").await.unwrap_err();
677 assert!(matches!(err, Error::PayloadTooLarge(_)), "got {err:?}");
678 assert_eq!(err.status_code(), 413);
679 }
680
681 #[tokio::test]
682 async fn save_as_writes_atomically() {
683 let dir = tempfile::tempdir().unwrap();
684 let mut builder = FormFileBuilder::new("f", "note.txt", None);
685 builder.write(b"content").await.unwrap();
686 let file = builder.finish().await.unwrap();
687
688 let dest = dir.path().join("nested/deep/note.txt");
689 file.save_as(&dest).await.unwrap();
690 assert_eq!(std::fs::read(&dest).unwrap(), b"content");
691
692 let leftovers: Vec<_> = std::fs::read_dir(dest.parent().unwrap())
694 .unwrap()
695 .filter_map(|e| e.ok())
696 .filter(|e| e.file_name().to_string_lossy().contains(".part"))
697 .collect();
698 assert!(leftovers.is_empty(), "staging file leaked: {leftovers:?}");
699 }
700
701 #[test]
702 fn form_lookup_helpers() {
703 let mut form = MultipartForm::new();
704 form.push_field("title", "hello");
705 form.push_field("tag", "a");
706 form.push_field("tag", "b");
707 assert_eq!(form.text("title"), Some("hello"));
708 assert_eq!(form.texts("tag").collect::<Vec<_>>(), vec!["a", "b"]);
709 assert!(form.contains("title"));
710 assert!(!form.contains("missing"));
711 assert_eq!(form.field_count(), 3);
712 }
713}