1mod spool;
41
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
54#[derive(Clone)]
60pub struct FormFile {
61 inner: Arc<FormFileInner>,
62}
63
64struct FormFileInner {
65 field_name: String,
66 original_file_name: String,
67 file_name: String,
68 content_type: Option<String>,
69 size: u64,
70 source: FileSource,
71}
72
73enum FileSource {
74 Memory(Bytes),
77 Disk {
79 path: PathBuf,
80 _guard: tempfile::TempPath,
81 },
82}
83
84struct SharedBytesReader {
86 data: Bytes,
87 position: usize,
88}
89
90impl AsyncRead for SharedBytesReader {
91 fn poll_read(
92 mut self: Pin<&mut Self>,
93 _cx: &mut Context<'_>,
94 buf: &mut ReadBuf<'_>,
95 ) -> Poll<std::io::Result<()>> {
96 let position = self.position;
97 let remaining = &self.data[position..];
98 if remaining.is_empty() {
99 return Poll::Ready(Ok(()));
100 }
101 let take = remaining.len().min(buf.remaining());
102 buf.put_slice(&remaining[..take]);
103 self.position = position + take;
104 Poll::Ready(Ok(()))
105 }
106}
107
108impl fmt::Debug for FormFile {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 f.debug_struct("FormFile")
111 .field("field_name", &self.inner.field_name)
112 .field("file_name", &self.inner.file_name)
113 .field("content_type", &self.inner.content_type)
114 .field("size", &self.inner.size)
115 .field(
116 "spooled_to_disk",
117 &matches!(self.inner.source, FileSource::Disk { .. }),
118 )
119 .finish()
120 }
121}
122
123impl FormFile {
124 pub fn field_name(&self) -> &str {
126 &self.inner.field_name
127 }
128
129 pub fn file_name(&self) -> &str {
135 &self.inner.file_name
136 }
137
138 pub fn original_file_name(&self) -> &str {
142 &self.inner.original_file_name
143 }
144
145 pub fn content_type(&self) -> Option<&str> {
150 self.inner.content_type.as_deref()
151 }
152
153 pub fn size(&self) -> u64 {
155 self.inner.size
156 }
157
158 pub fn is_empty(&self) -> bool {
160 self.inner.size == 0
161 }
162
163 pub fn extension(&self) -> Option<String> {
165 Path::new(&self.inner.file_name)
166 .extension()
167 .and_then(|e| e.to_str())
168 .map(|e| e.to_ascii_lowercase())
169 }
170
171 pub fn path(&self) -> Option<&Path> {
178 match &self.inner.source {
179 FileSource::Disk { path, .. } => Some(path.as_path()),
180 FileSource::Memory(_) => None,
181 }
182 }
183
184 pub async fn open(&self) -> Result<Box<dyn AsyncRead + Unpin + Send>> {
189 match &self.inner.source {
190 FileSource::Memory(bytes) => Ok(Box::new(SharedBytesReader {
191 data: Bytes::clone(bytes),
192 position: 0,
193 })),
194 FileSource::Disk { path, .. } => {
195 let file = tokio::fs::File::open(path).await.map_err(io_error)?;
196 Ok(Box::new(file))
197 }
198 }
199 }
200
201 pub async fn read_bytes(&self) -> Result<Vec<u8>> {
206 match &self.inner.source {
207 FileSource::Memory(bytes) => Ok(bytes.to_vec()),
208 FileSource::Disk { path, .. } => tokio::fs::read(path).await.map_err(io_error),
209 }
210 }
211
212 pub async fn copy_to<W>(&self, writer: &mut W) -> Result<u64>
217 where
218 W: AsyncWrite + Unpin + ?Sized,
219 {
220 let mut reader = self.open().await?;
221 let mut buffer = vec![0u8; STREAM_BUF_SIZE];
222 let mut total: u64 = 0;
223
224 loop {
225 let read = reader.read(&mut buffer).await.map_err(io_error)?;
226 if read == 0 {
227 return Ok(total);
228 }
229 writer.write_all(&buffer[..read]).await.map_err(io_error)?;
230 total += read as u64;
231 }
232 }
233
234 pub async fn save_as(&self, destination: impl AsRef<Path>) -> Result<()> {
239 let destination = destination.as_ref();
240 let parent = destination
241 .parent()
242 .filter(|p| !p.as_os_str().is_empty())
243 .unwrap_or_else(|| Path::new("."));
244 tokio::fs::create_dir_all(parent).await.map_err(io_error)?;
245
246 let staging = staging_path(destination);
247 let mut out = tokio::fs::File::create(&staging).await.map_err(io_error)?;
248
249 if let Err(err) = self.copy_to(&mut out).await {
250 drop(out);
251 let _ = tokio::fs::remove_file(&staging).await;
252 return Err(err);
253 }
254 out.flush().await.map_err(io_error)?;
255 out.sync_all().await.map_err(io_error)?;
256 drop(out);
257
258 if let Err(err) = tokio::fs::rename(&staging, destination).await {
259 let _ = tokio::fs::remove_file(&staging).await;
260 return Err(io_error(err));
261 }
262 Ok(())
263 }
264
265 fn from_parts(
266 field_name: String,
267 original_file_name: String,
268 content_type: Option<String>,
269 size: u64,
270 source: FileSource,
271 ) -> Self {
272 let file_name = sanitize_file_name(&original_file_name);
273 Self {
274 inner: Arc::new(FormFileInner {
275 field_name,
276 original_file_name,
277 file_name,
278 content_type,
279 size,
280 source,
281 }),
282 }
283 }
284}
285
286fn staging_path(destination: &Path) -> PathBuf {
291 let parent = destination
292 .parent()
293 .filter(|p| !p.as_os_str().is_empty())
294 .unwrap_or_else(|| Path::new("."));
295 let name = destination
296 .file_name()
297 .and_then(|n| n.to_str())
298 .unwrap_or("upload");
299 let nonce = std::time::SystemTime::now()
300 .duration_since(std::time::UNIX_EPOCH)
301 .map(|d| d.as_nanos())
302 .unwrap_or(0);
303 parent.join(format!(".{}.{}.{}.part", name, std::process::id(), nonce))
304}
305
306fn io_error(err: std::io::Error) -> Error {
307 Error::Internal(format!("file I/O error: {err}"))
308}
309
310pub struct FormFileBuilder {
316 field_name: String,
317 original_file_name: String,
318 content_type: Option<String>,
319 memory_threshold: usize,
320 max_size: u64,
321 spool_dir: PathBuf,
322 buffer: Vec<u8>,
323 spooled: Option<SpooledState>,
324 written: u64,
325}
326
327struct SpooledState {
328 writer: tokio::io::BufWriter<tokio::fs::File>,
332 guard: tempfile::TempPath,
333 path: PathBuf,
334}
335
336impl FormFileBuilder {
337 pub fn new(
342 field_name: impl Into<String>,
343 raw_file_name: impl Into<String>,
344 content_type: Option<String>,
345 ) -> Self {
346 Self {
347 field_name: field_name.into(),
348 original_file_name: raw_file_name.into(),
349 content_type,
350 memory_threshold: 1024 * 1024,
351 max_size: 128 * 1024 * 1024,
352 spool_dir: spool_root(),
353 buffer: Vec::new(),
354 spooled: None,
355 written: 0,
356 }
357 }
358
359 pub fn memory_threshold(mut self, bytes: usize) -> Self {
361 self.memory_threshold = bytes;
362 self
363 }
364
365 pub fn max_size(mut self, bytes: u64) -> Self {
369 self.max_size = bytes;
370 self
371 }
372
373 pub fn spool_dir(mut self, dir: impl Into<PathBuf>) -> Self {
375 self.spool_dir = dir.into();
376 self
377 }
378
379 pub async fn write(&mut self, chunk: &[u8]) -> Result<()> {
385 if chunk.is_empty() {
386 return Ok(());
387 }
388 self.written = self.written.saturating_add(chunk.len() as u64);
389 if self.written > self.max_size {
390 return Err(Error::PayloadTooLarge(format!(
391 "file field `{}` exceeds the {}-byte limit",
392 self.field_name, self.max_size
393 )));
394 }
395
396 if self.spooled.is_none() && self.buffer.len() + chunk.len() > self.memory_threshold {
397 self.spill().await?;
398 }
399
400 match &mut self.spooled {
401 Some(state) => {
402 state.writer.write_all(chunk).await.map_err(io_error)?;
403 }
404 None => self.buffer.extend_from_slice(chunk),
405 }
406 Ok(())
407 }
408
409 pub async fn finish(mut self) -> Result<FormFile> {
411 if let Some(state) = self.spooled.take() {
412 let SpooledState {
413 mut writer,
414 guard,
415 path,
416 } = state;
417 writer.flush().await.map_err(io_error)?;
420 let file = writer.into_inner();
421 file.sync_all().await.map_err(io_error)?;
422 drop(file);
423 Ok(FormFile::from_parts(
424 self.field_name,
425 self.original_file_name,
426 self.content_type,
427 self.written,
428 FileSource::Disk {
429 path,
430 _guard: guard,
431 },
432 ))
433 } else {
434 Ok(FormFile::from_parts(
435 self.field_name,
436 self.original_file_name,
437 self.content_type,
438 self.written,
439 FileSource::Memory(Bytes::from(std::mem::take(&mut self.buffer))),
440 ))
441 }
442 }
443
444 async fn spill(&mut self) -> Result<()> {
445 tokio::fs::create_dir_all(&self.spool_dir)
446 .await
447 .map_err(io_error)?;
448 let named = tempfile::Builder::new()
449 .prefix(SPOOL_FILE_PREFIX)
450 .tempfile_in(&self.spool_dir)
451 .map_err(io_error)?;
452 let (std_file, guard) = named.into_parts();
453 let path = guard.to_path_buf();
454 let mut writer = tokio::io::BufWriter::with_capacity(
455 STREAM_BUF_SIZE,
456 tokio::fs::File::from_std(std_file),
457 );
458 if !self.buffer.is_empty() {
459 writer.write_all(&self.buffer).await.map_err(io_error)?;
460 }
461 self.buffer = Vec::new();
462 self.buffer.shrink_to_fit();
463 self.spooled = Some(SpooledState {
464 writer,
465 guard,
466 path,
467 });
468 Ok(())
469 }
470}
471
472#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct FormField {
475 pub name: String,
477 pub value: String,
479}
480
481#[derive(Debug, Clone, Default)]
485pub struct MultipartForm {
486 fields: Vec<FormField>,
487 files: Vec<FormFile>,
488}
489
490impl MultipartForm {
491 pub fn new() -> Self {
493 Self::default()
494 }
495
496 pub fn push_field(&mut self, name: impl Into<String>, value: impl Into<String>) {
498 let name = name.into();
499 if name.is_empty() {
500 return;
501 }
502 self.fields.push(FormField {
503 name,
504 value: value.into(),
505 });
506 }
507
508 pub fn push_file(&mut self, file: FormFile) {
510 self.files.push(file);
511 }
512
513 pub fn fields(&self) -> &[FormField] {
515 &self.fields
516 }
517
518 pub fn files(&self) -> &[FormFile] {
520 &self.files
521 }
522
523 pub fn text<'a>(&'a self, name: &str) -> Option<&'a str> {
525 self.texts(name).next()
526 }
527
528 pub fn texts<'a, 'n>(&'a self, name: &'n str) -> impl Iterator<Item = &'a str> + 'n
530 where
531 'a: 'n,
532 {
533 self.fields
534 .iter()
535 .filter(move |f| f.name == name)
536 .map(|f| f.value.as_str())
537 }
538
539 pub fn file<'a>(&'a self, name: &str) -> Option<&'a FormFile> {
541 self.files_named(name).next()
542 }
543
544 pub fn files_named<'a, 'n>(&'a self, name: &'n str) -> impl Iterator<Item = &'a FormFile> + 'n
546 where
547 'a: 'n,
548 {
549 self.files.iter().filter(move |f| f.field_name() == name)
550 }
551
552 pub fn contains(&self, name: &str) -> bool {
554 self.fields.iter().any(|f| f.name == name) || self.file(name).is_some()
555 }
556
557 pub fn is_empty(&self) -> bool {
559 self.fields.is_empty() && self.files.is_empty()
560 }
561
562 pub fn field_count(&self) -> usize {
564 self.fields.len()
565 }
566
567 pub fn file_count(&self) -> usize {
569 self.files.len()
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 #[test]
578 fn sanitize_strips_path_traversal() {
579 assert_eq!(sanitize_file_name("../../etc/passwd"), "passwd");
580 assert_eq!(sanitize_file_name(r"..\..\windows\win.ini"), "win.ini");
581 assert_eq!(sanitize_file_name("/absolute/path/a.txt"), "a.txt");
582 }
583
584 #[test]
585 fn sanitize_rejects_empty_and_dot_names() {
586 assert_eq!(sanitize_file_name(""), "file");
587 assert_eq!(sanitize_file_name(" "), "file");
588 assert_eq!(sanitize_file_name(".."), "file");
589 assert_eq!(sanitize_file_name("."), "file");
590 assert_eq!(sanitize_file_name("trailing."), "trailing");
591 }
592
593 #[test]
594 fn sanitize_replaces_reserved_characters() {
595 assert_eq!(sanitize_file_name("a:b|c?.txt"), "a_b_c_.txt");
596 assert_eq!(sanitize_file_name("nul\u{0}byte.txt"), "nulbyte.txt");
597 }
598
599 #[test]
600 fn sanitize_keeps_leading_dot_names() {
601 assert_eq!(sanitize_file_name(".env"), ".env");
602 }
603
604 #[test]
605 fn sanitize_caps_length() {
606 let long = "x".repeat(500) + ".txt";
607 let out = sanitize_file_name(&long);
608 assert!(out.len() <= 200, "got {} bytes", out.len());
609 assert!(out.ends_with(".txt"));
610 }
611
612 #[tokio::test]
613 async fn builder_keeps_small_files_in_memory() {
614 let mut builder = FormFileBuilder::new("avatar", "a.png", Some("image/png".into()))
615 .memory_threshold(1024);
616 builder.write(b"hello").await.unwrap();
617 let file = builder.finish().await.unwrap();
618 assert_eq!(file.size(), 5);
619 assert!(file.path().is_none());
620 assert_eq!(file.read_bytes().await.unwrap(), b"hello");
621 assert_eq!(file.content_type(), Some("image/png"));
622 assert_eq!(file.extension().as_deref(), Some("png"));
623 }
624
625 #[tokio::test]
626 async fn builder_spills_large_files_to_disk_and_cleans_up() {
627 let dir = tempfile::tempdir().unwrap();
628 let mut builder = FormFileBuilder::new("blob", "big.bin", None)
629 .memory_threshold(8)
630 .spool_dir(dir.path().to_path_buf());
631 builder.write(b"0123456789").await.unwrap();
632 builder.write(b"abcdefghij").await.unwrap();
633 let file = builder.finish().await.unwrap();
634
635 assert_eq!(file.size(), 20);
636 let path = file.path().expect("should be spooled").to_path_buf();
637 assert!(path.exists());
638 assert_eq!(file.read_bytes().await.unwrap(), b"0123456789abcdefghij");
639
640 drop(file);
641 assert!(!path.exists(), "spooled file must be deleted on drop");
642 }
643
644 #[tokio::test]
645 async fn builder_enforces_max_size() {
646 let mut builder = FormFileBuilder::new("f", "f.bin", None).max_size(4);
647 let err = builder.write(b"12345").await.unwrap_err();
648 assert!(matches!(err, Error::PayloadTooLarge(_)), "got {err:?}");
649 assert_eq!(err.status_code(), 413);
650 }
651
652 #[tokio::test]
653 async fn save_as_writes_atomically() {
654 let dir = tempfile::tempdir().unwrap();
655 let mut builder = FormFileBuilder::new("f", "note.txt", None);
656 builder.write(b"content").await.unwrap();
657 let file = builder.finish().await.unwrap();
658
659 let dest = dir.path().join("nested/deep/note.txt");
660 file.save_as(&dest).await.unwrap();
661 assert_eq!(std::fs::read(&dest).unwrap(), b"content");
662
663 let leftovers: Vec<_> = std::fs::read_dir(dest.parent().unwrap())
665 .unwrap()
666 .filter_map(|e| e.ok())
667 .filter(|e| e.file_name().to_string_lossy().contains(".part"))
668 .collect();
669 assert!(leftovers.is_empty(), "staging file leaked: {leftovers:?}");
670 }
671
672 #[test]
673 fn form_lookup_helpers() {
674 let mut form = MultipartForm::new();
675 form.push_field("title", "hello");
676 form.push_field("tag", "a");
677 form.push_field("tag", "b");
678 assert_eq!(form.text("title"), Some("hello"));
679 assert_eq!(form.texts("tag").collect::<Vec<_>>(), vec!["a", "b"]);
680 assert!(form.contains("title"));
681 assert!(!form.contains("missing"));
682 assert_eq!(form.field_count(), 3);
683 }
684}