Skip to main content

webx_core/form/
mod.rs

1//! Multipart form binding — typed access to `multipart/form-data` uploads.
2//!
3//! This module provides the framework's file-upload primitives:
4//!
5//! - [`FormFile`] — one uploaded file, with metadata and streaming access.
6//! - [`MultipartForm`] — the parsed form: text fields plus uploaded files.
7//! - [`FormFileBuilder`] — incremental spooling used by the HTTP layer.
8//!
9//! # How files are stored
10//!
11//! A file part is buffered in memory until it exceeds `memory_threshold`, then
12//! it is spooled to a temporary file under the configured spool directory.
13//! Either way the uploaded bytes live in exactly one place and are never copied
14//! into a `String`, so a 1 GB upload costs a bounded amount of RSS.
15//!
16//! Spooled files are owned by the [`FormFile`] handles that reference them and
17//! are deleted when the last handle is dropped — that is, when the request
18//! struct is dropped at the end of the request. Call [`FormFile::save_as`] (or
19//! [`FormFile::copy_to`]) from your handler to keep the bytes.
20//!
21//! # Binding
22//!
23//! Request structs declare form fields with `#[derive(WebxRequestMeta)]`:
24//! `FormFile` fields become file parts, unmarked text fields (or fields marked
25//! `#[fromform]`) become text parts, and route parameters still bind by name.
26//!
27//! ```ignore
28//! #[derive(WebxRequestMeta)]
29//! struct UploadRequest {
30//!     title: String,          // text part
31//!     avatar: FormFile,       // file part
32//!     gallery: Vec<FormFile>, // repeated file parts
33//! }
34//! ```
35//!
36//! `FormFile` has no `Deserialize` implementation: a file handle can only come
37//! from a parsed multipart body, so a client cannot make the server read an
38//! arbitrary path by posting JSON.
39
40mod 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/// One file part of a `multipart/form-data` request.
55///
56/// Cloning a `FormFile` is cheap: all clones share the same underlying bytes or
57/// spooled temporary file, and the temporary file is removed when the last
58/// clone is dropped.
59#[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    /// Small parts stay in memory, behind a reference-counted buffer so opening
75    /// them for reading does not copy the bytes.
76    Memory(Bytes),
77    /// Large parts are spooled; `_guard` deletes the file on drop.
78    Disk {
79        path: PathBuf,
80        _guard: tempfile::TempPath,
81    },
82}
83
84/// Zero-copy reader over a shared [`Bytes`] buffer.
85struct 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    /// The form field name this file arrived under (e.g. `"avatar"`).
125    pub fn field_name(&self) -> &str {
126        &self.inner.field_name
127    }
128
129    /// A filesystem-safe basename derived from the client-supplied filename.
130    ///
131    /// Path separators, control characters and Windows-reserved characters are
132    /// removed, so this is safe to pass to [`Path::join`]. Use
133    /// [`FormFile::original_file_name`] if you need the raw value for display.
134    pub fn file_name(&self) -> &str {
135        &self.inner.file_name
136    }
137
138    /// The filename exactly as sent by the client, without sanitisation.
139    ///
140    /// Never use this to build a filesystem path.
141    pub fn original_file_name(&self) -> &str {
142        &self.inner.original_file_name
143    }
144
145    /// The part's declared content type, if the client sent one.
146    ///
147    /// This is attacker-controlled metadata: treat it as a hint, not a
148    /// guarantee.
149    pub fn content_type(&self) -> Option<&str> {
150        self.inner.content_type.as_deref()
151    }
152
153    /// Size of the uploaded content in bytes.
154    pub fn size(&self) -> u64 {
155        self.inner.size
156    }
157
158    /// Whether the uploaded content is empty.
159    pub fn is_empty(&self) -> bool {
160        self.inner.size == 0
161    }
162
163    /// Lowercase file extension of [`FormFile::file_name`], without the dot.
164    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    /// The spooled temporary file path, when the upload was large enough to be
172    /// written to disk.
173    ///
174    /// The path is only valid while this `FormFile` (or a clone) is alive.
175    /// Prefer [`FormFile::save_as`] to hand a file to the rest of your
176    /// application.
177    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    /// Open the uploaded content as an async reader.
185    ///
186    /// Memory-backed files read from a reference-counted buffer without copying
187    /// it; spooled files are opened from disk. Either way the read is streamed.
188    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    /// Read the whole upload into memory.
202    ///
203    /// Prefer [`FormFile::open`] or [`FormFile::copy_to`] for files that may be
204    /// large.
205    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    /// Stream the upload into `writer`, returning the number of bytes written.
213    ///
214    /// Uses the framework's 64 KiB copy buffer rather than the 8 KiB default,
215    /// which matters when a multi-gigabyte upload is being persisted.
216    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    /// Stream the upload to `destination`, creating parent directories.
235    ///
236    /// The bytes are written to a sibling temporary file first and renamed into
237    /// place, so `destination` never contains a partially written upload.
238    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
286/// A path in the same directory as `destination`, used for atomic writes.
287///
288/// Keeping the staging file next to the target guarantees the final
289/// [`tokio::fs::rename`] stays on one filesystem.
290fn 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
310/// Incrementally builds a [`FormFile`] while a multipart part streams in.
311///
312/// The builder buffers in memory up to `memory_threshold` bytes and spills to a
313/// temporary file beyond that, so peak memory per upload is bounded regardless
314/// of file size.
315pub 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    /// Buffered on purpose: `tokio::fs::File` dispatches every write to the
329    /// blocking pool, so coalescing transport-sized chunks into one write per
330    /// buffer saves a thread hop per chunk.
331    writer: tokio::io::BufWriter<tokio::fs::File>,
332    guard: tempfile::TempPath,
333    path: PathBuf,
334}
335
336impl FormFileBuilder {
337    /// Start building a file for `field_name`.
338    ///
339    /// `raw_file_name` is the client-supplied name; it is sanitised by
340    /// [`sanitize_file_name`] when the file is finished.
341    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    /// Bytes kept in memory before spilling to disk. Default: 1 MiB.
360    pub fn memory_threshold(mut self, bytes: usize) -> Self {
361        self.memory_threshold = bytes;
362        self
363    }
364
365    /// Maximum accepted size for this file. Default: 128 MiB.
366    ///
367    /// Exceeding it fails the upload with [`Error::PayloadTooLarge`].
368    pub fn max_size(mut self, bytes: u64) -> Self {
369        self.max_size = bytes;
370        self
371    }
372
373    /// Directory used for spooled files. Default: [`spool_root`].
374    pub fn spool_dir(mut self, dir: impl Into<PathBuf>) -> Self {
375        self.spool_dir = dir.into();
376        self
377    }
378
379    /// Append a chunk of the current part.
380    ///
381    /// Returns [`Error::PayloadTooLarge`] as soon as the file exceeds
382    /// [`FormFileBuilder::max_size`], so an oversized upload is rejected
383    /// without buffering the whole body.
384    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    /// Finish the part and produce the [`FormFile`].
410    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            // `BufWriter::into_inner` drops whatever is still buffered, so the
418            // flush has to happen first.
419            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/// One text part of a multipart form.
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct FormField {
475    /// Field name as sent by the client.
476    pub name: String,
477    /// Field value decoded as UTF-8.
478    pub value: String,
479}
480
481/// A parsed `multipart/form-data` body.
482///
483/// Cheap to clone: files are reference-counted handles.
484#[derive(Debug, Clone, Default)]
485pub struct MultipartForm {
486    fields: Vec<FormField>,
487    files: Vec<FormFile>,
488}
489
490impl MultipartForm {
491    /// Create an empty form.
492    pub fn new() -> Self {
493        Self::default()
494    }
495
496    /// Append a text field. Empty names are ignored.
497    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    /// Append an uploaded file.
509    pub fn push_file(&mut self, file: FormFile) {
510        self.files.push(file);
511    }
512
513    /// All text fields, in the order they appeared.
514    pub fn fields(&self) -> &[FormField] {
515        &self.fields
516    }
517
518    /// All uploaded files, in the order they appeared.
519    pub fn files(&self) -> &[FormFile] {
520        &self.files
521    }
522
523    /// The first text value for `name`.
524    pub fn text<'a>(&'a self, name: &str) -> Option<&'a str> {
525        self.texts(name).next()
526    }
527
528    /// Every text value for `name`, in order.
529    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    /// The first uploaded file for `name`.
540    pub fn file<'a>(&'a self, name: &str) -> Option<&'a FormFile> {
541        self.files_named(name).next()
542    }
543
544    /// Every uploaded file for `name`, in order.
545    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    /// Whether any field or file uses `name`.
553    pub fn contains(&self, name: &str) -> bool {
554        self.fields.iter().any(|f| f.name == name) || self.file(name).is_some()
555    }
556
557    /// Whether the form carries neither fields nor files.
558    pub fn is_empty(&self) -> bool {
559        self.fields.is_empty() && self.files.is_empty()
560    }
561
562    /// Number of text fields.
563    pub fn field_count(&self) -> usize {
564        self.fields.len()
565    }
566
567    /// Number of uploaded files.
568    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        // No staging files left behind.
664        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}