Skip to main content

rust_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 bind form data through ordinary `serde`:
24//!
25//! ```ignore
26//! #[derive(Deserialize)]
27//! struct UploadRequest {
28//!     title: String,          // text field
29//!     avatar: FormFile,       // file part
30//!     gallery: Vec<FormFile>, // repeated file parts
31//! }
32//! ```
33//!
34//! `FormFile` deliberately only deserializes from the framework's own spool
35//! directory, so a client cannot make the server read arbitrary paths by
36//! posting JSON.
37
38mod 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    /// The form currently being bound, used to hand `FormFile` values to
56    /// `serde` while preserving their reference-counted ownership.
57    static ACTIVE_FORM: Arc<MultipartForm>;
58}
59
60/// Task-local access to the form being deserialized.
61///
62/// `serde` cannot move an `Arc` through a `Visitor`, so file parts are passed
63/// as an index into the active form instead. Binding runs inside
64/// [`ActiveForm::run`], which makes the lookup safe: a `FormFile` can only ever
65/// be produced while the framework is deserializing a real parsed form.
66pub(crate) struct ActiveForm;
67
68impl ActiveForm {
69    /// Run `f` with `form` available to `FormFile::deserialize`.
70    pub(crate) fn run<R>(form: Arc<MultipartForm>, f: impl FnOnce() -> R) -> R {
71        ACTIVE_FORM.sync_scope(form, f)
72    }
73
74    /// Resolve a file index produced by [`FormDeserializer`].
75    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/// One file part of a `multipart/form-data` request.
84///
85/// Cloning a `FormFile` is cheap: all clones share the same underlying bytes or
86/// spooled temporary file, and the temporary file is removed when the last
87/// clone is dropped.
88#[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    /// Small parts stay in memory, behind a reference-counted buffer so opening
104    /// them for reading does not copy the bytes.
105    Memory(Bytes),
106    /// Large parts are spooled; `_guard` deletes the file on drop.
107    Disk {
108        path: PathBuf,
109        _guard: tempfile::TempPath,
110    },
111}
112
113/// Zero-copy reader over a shared [`Bytes`] buffer.
114struct 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    /// The form field name this file arrived under (e.g. `"avatar"`).
151    pub fn field_name(&self) -> &str {
152        &self.inner.field_name
153    }
154
155    /// A filesystem-safe basename derived from the client-supplied filename.
156    ///
157    /// Path separators, control characters and Windows-reserved characters are
158    /// removed, so this is safe to pass to [`Path::join`]. Use
159    /// [`FormFile::original_file_name`] if you need the raw value for display.
160    pub fn file_name(&self) -> &str {
161        &self.inner.file_name
162    }
163
164    /// The filename exactly as sent by the client, without sanitisation.
165    ///
166    /// Never use this to build a filesystem path.
167    pub fn original_file_name(&self) -> &str {
168        &self.inner.original_file_name
169    }
170
171    /// The part's declared content type, if the client sent one.
172    ///
173    /// This is attacker-controlled metadata: treat it as a hint, not a
174    /// guarantee.
175    pub fn content_type(&self) -> Option<&str> {
176        self.inner.content_type.as_deref()
177    }
178
179    /// Size of the uploaded content in bytes.
180    pub fn size(&self) -> u64 {
181        self.inner.size
182    }
183
184    /// Whether the uploaded content is empty.
185    pub fn is_empty(&self) -> bool {
186        self.inner.size == 0
187    }
188
189    /// Lowercase file extension of [`FormFile::file_name`], without the dot.
190    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    /// The spooled temporary file path, when the upload was large enough to be
198    /// written to disk.
199    ///
200    /// The path is only valid while this `FormFile` (or a clone) is alive.
201    /// Prefer [`FormFile::save_as`] to hand a file to the rest of your
202    /// application.
203    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    /// Open the uploaded content as an async reader.
211    ///
212    /// Memory-backed files read from a reference-counted buffer without copying
213    /// it; spooled files are opened from disk. Either way the read is streamed.
214    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    /// Read the whole upload into memory.
228    ///
229    /// Prefer [`FormFile::open`] or [`FormFile::copy_to`] for files that may be
230    /// large.
231    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    /// Stream the upload into `writer`, returning the number of bytes written.
239    ///
240    /// Uses the framework's 64 KiB copy buffer rather than the 8 KiB default,
241    /// which matters when a multi-gigabyte upload is being persisted.
242    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    /// Stream the upload to `destination`, creating parent directories.
264    ///
265    /// The bytes are written to a sibling temporary file first and renamed into
266    /// place, so `destination` never contains a partially written upload.
267    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
315/// A path in the same directory as `destination`, used for atomic writes.
316///
317/// Keeping the staging file next to the target guarantees the final
318/// [`tokio::fs::rename`] stays on one filesystem.
319fn 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
339/// Incrementally builds a [`FormFile`] while a multipart part streams in.
340///
341/// The builder buffers in memory up to `memory_threshold` bytes and spills to a
342/// temporary file beyond that, so peak memory per upload is bounded regardless
343/// of file size.
344pub 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    /// Buffered on purpose: `tokio::fs::File` dispatches every write to the
358    /// blocking pool, so coalescing transport-sized chunks into one write per
359    /// buffer saves a thread hop per chunk.
360    writer: tokio::io::BufWriter<tokio::fs::File>,
361    guard: tempfile::TempPath,
362    path: PathBuf,
363}
364
365impl FormFileBuilder {
366    /// Start building a file for `field_name`.
367    ///
368    /// `raw_file_name` is the client-supplied name; it is sanitised by
369    /// [`sanitize_file_name`] when the file is finished.
370    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    /// Bytes kept in memory before spilling to disk. Default: 1 MiB.
389    pub fn memory_threshold(mut self, bytes: usize) -> Self {
390        self.memory_threshold = bytes;
391        self
392    }
393
394    /// Maximum accepted size for this file. Default: 128 MiB.
395    ///
396    /// Exceeding it fails the upload with [`Error::PayloadTooLarge`].
397    pub fn max_size(mut self, bytes: u64) -> Self {
398        self.max_size = bytes;
399        self
400    }
401
402    /// Directory used for spooled files. Default: [`spool_root`].
403    pub fn spool_dir(mut self, dir: impl Into<PathBuf>) -> Self {
404        self.spool_dir = dir.into();
405        self
406    }
407
408    /// Append a chunk of the current part.
409    ///
410    /// Returns [`Error::PayloadTooLarge`] as soon as the file exceeds
411    /// [`FormFileBuilder::max_size`], so an oversized upload is rejected
412    /// without buffering the whole body.
413    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    /// Finish the part and produce the [`FormFile`].
439    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            // `BufWriter::into_inner` drops whatever is still buffered, so the
447            // flush has to happen first.
448            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/// One text part of a multipart form.
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct FormField {
504    /// Field name as sent by the client.
505    pub name: String,
506    /// Field value decoded as UTF-8.
507    pub value: String,
508}
509
510/// A parsed `multipart/form-data` body.
511///
512/// Cheap to clone: files are reference-counted handles.
513#[derive(Debug, Clone, Default)]
514pub struct MultipartForm {
515    fields: Vec<FormField>,
516    files: Vec<FormFile>,
517}
518
519impl MultipartForm {
520    /// Create an empty form.
521    pub fn new() -> Self {
522        Self::default()
523    }
524
525    /// Append a text field. Empty names are ignored.
526    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    /// Append an uploaded file.
538    pub fn push_file(&mut self, file: FormFile) {
539        self.files.push(file);
540    }
541
542    /// All text fields, in the order they appeared.
543    pub fn fields(&self) -> &[FormField] {
544        &self.fields
545    }
546
547    /// All uploaded files, in the order they appeared.
548    pub fn files(&self) -> &[FormFile] {
549        &self.files
550    }
551
552    /// The first text value for `name`.
553    pub fn text<'a>(&'a self, name: &str) -> Option<&'a str> {
554        self.texts(name).next()
555    }
556
557    /// Every text value for `name`, in order.
558    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    /// The first uploaded file for `name`.
569    pub fn file<'a>(&'a self, name: &str) -> Option<&'a FormFile> {
570        self.files_named(name).next()
571    }
572
573    /// Every uploaded file for `name`, in order.
574    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    /// Whether any field or file uses `name`.
582    pub fn contains(&self, name: &str) -> bool {
583        self.fields.iter().any(|f| f.name == name) || self.file(name).is_some()
584    }
585
586    /// Whether the form carries neither fields nor files.
587    pub fn is_empty(&self) -> bool {
588        self.fields.is_empty() && self.files.is_empty()
589    }
590
591    /// Number of text fields.
592    pub fn field_count(&self) -> usize {
593        self.fields.len()
594    }
595
596    /// Number of uploaded files.
597    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        // No staging files left behind.
693        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}