Skip to main content

form_net/
error.rs

1//! The error type: one `Copy` enum for the whole crate, its reason enums,
2//! and where an error was found.
3//!
4//! No error ever holds input bytes, a field value, a file name, or a path, so
5//! every error is safe to log.
6
7use core::fmt;
8
9use crate::hook::Refusal;
10
11/// Everything that can go wrong in form-net.
12///
13/// Input errors (`Limit`, `Syntax`, `Encoding`, `Policy`) carry a
14/// [`Location`]; caller mistakes (`InvalidLimits`, `BufferTooSmall`,
15/// `Poisoned`) and environment failures (`Io`, `Random`) do not.
16/// [`status_hint`](Self::status_hint) maps every error to an HTTP status and
17/// [`code`](Self::code) to a stable name.
18///
19/// # Examples
20///
21/// ```
22/// use form_net::{FormError, Limit};
23///
24/// let err = form_net::parse_urlencoded(&vec![b'a'; 11 << 20]).unwrap_err();
25/// assert!(matches!(err, FormError::Limit { limit: Limit::Body, .. }));
26/// assert_eq!((err.status_hint(), err.code()), (413, "limit.body"));
27/// ```
28#[non_exhaustive]
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum FormError {
31    /// A budget of [`Limits`](crate::Limits) was exceeded.
32    Limit {
33        /// Which limit.
34        limit: Limit,
35        /// Where.
36        at: Location,
37    },
38    /// The input breaks the grammar.
39    Syntax {
40        /// What was wrong.
41        reason: SyntaxError,
42        /// Where.
43        at: Location,
44    },
45    /// Bytes that do not decode: percent escapes, UTF-8, charsets.
46    Encoding {
47        /// What was wrong.
48        reason: EncodingError,
49        /// Where.
50        at: Location,
51    },
52    /// The input is well formed but a [`Policy`](crate::Policy) refuses it.
53    Policy {
54        /// Which rule.
55        reason: PolicyRefusal,
56        /// Where.
57        at: Location,
58    },
59    /// An inspector refused the input.
60    Refused {
61        /// The inspector's status and code.
62        refusal: Refusal,
63        /// Where.
64        at: Location,
65    },
66    /// Typed extraction failed.
67    Extract {
68        /// What was wrong.
69        reason: ExtractError,
70        /// The Rust-side field name, when there is one.
71        field: Option<&'static str>,
72    },
73    /// The content type is not one form-net parses.
74    UnsupportedMediaType,
75    /// [`Limits::validate`](crate::Limits::validate) refused the
76    /// configuration.
77    InvalidLimits {
78        /// The field it refused.
79        limit: Limit,
80    },
81    /// A buffer the caller provided is too short.
82    BufferTooSmall {
83        /// The length it must have.
84        needed: usize,
85    },
86    /// The reader, the writer, or the file system failed.
87    #[cfg(feature = "std")]
88    #[cfg_attr(docsrs, doc(cfg(feature = "std")))]
89    Io {
90        /// The kind of failure; never a path or an OS message.
91        kind: std::io::ErrorKind,
92    },
93    /// The operating system's random source failed.
94    #[cfg(feature = "std")]
95    #[cfg_attr(docsrs, doc(cfg(feature = "std")))]
96    Random,
97    /// Storage refused: the shared budget is full, a path is not a
98    /// directory, or a `persist` destination exists.
99    #[cfg(feature = "fs")]
100    #[cfg_attr(docsrs, doc(cfg(feature = "fs")))]
101    Store {
102        /// What went wrong.
103        reason: crate::store::StoreError,
104    },
105    /// A parser was called again after an error, without `reset`.
106    Poisoned,
107}
108
109impl FormError {
110    pub(crate) const fn limit(limit: Limit, at: Location) -> Self {
111        Self::Limit { limit, at }
112    }
113
114    pub(crate) const fn syntax(reason: SyntaxError, at: Location) -> Self {
115        Self::Syntax { reason, at }
116    }
117
118    pub(crate) const fn encoding(reason: EncodingError, at: Location) -> Self {
119        Self::Encoding { reason, at }
120    }
121
122    pub(crate) const fn policy(reason: PolicyRefusal, at: Location) -> Self {
123        Self::Policy { reason, at }
124    }
125
126    /// The same error with its location moved `by` bytes later, for errors
127    /// found inside a component that starts at `by`.
128    #[must_use]
129    pub(crate) const fn shifted(self, by: u64) -> Self {
130        match self {
131            Self::Limit { limit, at } => Self::Limit {
132                limit,
133                at: at.shifted(by),
134            },
135            Self::Syntax { reason, at } => Self::Syntax {
136                reason,
137                at: at.shifted(by),
138            },
139            Self::Encoding { reason, at } => Self::Encoding {
140                reason,
141                at: at.shifted(by),
142            },
143            Self::Policy { reason, at } => Self::Policy {
144                reason,
145                at: at.shifted(by),
146            },
147            Self::Refused { refusal, at } => Self::Refused {
148                refusal,
149                at: at.shifted(by),
150            },
151            other => other,
152        }
153    }
154
155    /// The HTTP status a server should answer with.
156    ///
157    /// 413 for every limit, 400 for syntax and most encoding and policy
158    /// errors, 415 for unsupported media types, charsets, transfer encodings,
159    /// and nested multipart, 422 for extraction errors, 503 when the shared
160    /// spill budget is full, 500 for caller mistakes and environment
161    /// failures, and the inspector's own status for a refusal (400 if that
162    /// status is outside 400 to 599).
163    ///
164    /// # Examples
165    ///
166    /// ```
167    /// use form_net::FormError;
168    ///
169    /// assert_eq!(FormError::UnsupportedMediaType.status_hint(), 415);
170    /// assert_eq!(FormError::Poisoned.status_hint(), 500);
171    /// ```
172    #[must_use]
173    pub const fn status_hint(&self) -> u16 {
174        match self {
175            Self::Limit { .. } => 413,
176            Self::Syntax { .. } => 400,
177            Self::Encoding { reason, .. } => match reason {
178                EncodingError::UnsupportedCharset => 415,
179                _ => 400,
180            },
181            Self::Policy { reason, .. } => match reason {
182                PolicyRefusal::TransferEncoding | PolicyRefusal::NestedMultipart => 415,
183                _ => 400,
184            },
185            Self::Refused { refusal, .. } => {
186                if refusal.status >= 400 && refusal.status <= 599 {
187                    refusal.status
188                } else {
189                    400
190                }
191            }
192            Self::Extract { .. } => 422,
193            Self::UnsupportedMediaType => 415,
194            Self::InvalidLimits { .. } | Self::BufferTooSmall { .. } | Self::Poisoned => 500,
195            #[cfg(feature = "std")]
196            Self::Io { .. } | Self::Random => 500,
197            #[cfg(feature = "fs")]
198            Self::Store { reason } => match reason {
199                crate::store::StoreError::QuotaExceeded => 503,
200                _ => 500,
201            },
202        }
203    }
204
205    /// A stable, machine-readable name for the error: the variant and the
206    /// reason in snake case, joined with a dot (`limit.parts`,
207    /// `syntax.boundary_in_content`). A refusal returns the inspector's own
208    /// [`Refusal::code`]. Codes never change meaning after 1.0.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use form_net::FormError;
214    ///
215    /// assert_eq!(FormError::UnsupportedMediaType.code(), "unsupported_media_type");
216    /// let err = form_net::parse_urlencoded(b"a=%zz").unwrap_err();
217    /// assert_eq!(err.code(), "encoding.invalid_percent");
218    /// ```
219    #[must_use]
220    pub const fn code(&self) -> &'static str {
221        match self {
222            Self::Limit { limit, .. } => limit.code(),
223            Self::Syntax { reason, .. } => reason.code(),
224            Self::Encoding { reason, .. } => reason.code(),
225            Self::Policy { reason, .. } => reason.code(),
226            Self::Refused { refusal, .. } => refusal.code,
227            Self::Extract { reason, .. } => reason.code(),
228            Self::UnsupportedMediaType => "unsupported_media_type",
229            Self::InvalidLimits { .. } => "invalid_limits",
230            Self::BufferTooSmall { .. } => "buffer_too_small",
231            #[cfg(feature = "std")]
232            Self::Io { .. } => "io",
233            #[cfg(feature = "std")]
234            Self::Random => "random",
235            #[cfg(feature = "fs")]
236            Self::Store { reason } => reason.code(),
237            Self::Poisoned => "poisoned",
238        }
239    }
240
241    /// Where the error was found, for input errors and refusals.
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// let err = form_net::parse_urlencoded(b"ok=1&bad=%zz").unwrap_err();
247    /// assert_eq!(err.location().map(|l| l.offset), Some(9));
248    /// assert_eq!(form_net::FormError::Poisoned.location(), None);
249    /// ```
250    #[must_use]
251    pub const fn location(&self) -> Option<Location> {
252        match self {
253            Self::Limit { at, .. }
254            | Self::Syntax { at, .. }
255            | Self::Encoding { at, .. }
256            | Self::Policy { at, .. }
257            | Self::Refused { at, .. } => Some(*at),
258            _ => None,
259        }
260    }
261}
262
263impl fmt::Display for FormError {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        match self {
266            Self::Limit { limit, at } => {
267                write!(f, "{} ({at}): {}", limit.describe(), limit.action())
268            }
269            Self::Syntax { reason, at } => write!(f, "{} ({at})", reason.describe()),
270            Self::Encoding { reason, at } => write!(f, "{} ({at})", reason.describe()),
271            Self::Policy { reason, at } => write!(f, "{} ({at})", reason.describe()),
272            Self::Refused { refusal, at } => {
273                write!(f, "refused by the inspector: {} ({at})", refusal.code)
274            }
275            Self::Extract { reason, field } => match field {
276                Some(name) => write!(f, "{} (field `{name}`)", reason.describe()),
277                None => f.write_str(reason.describe()),
278            },
279            Self::UnsupportedMediaType => f.write_str(
280                "unsupported media type: only application/x-www-form-urlencoded and multipart/form-data are parsed",
281            ),
282            Self::InvalidLimits { limit } => write!(
283                f,
284                "invalid limits: {} cannot be satisfied by any body; fix the configuration",
285                limit.field_name()
286            ),
287            Self::BufferTooSmall { needed } => write!(
288                f,
289                "buffer too small: {needed} bytes needed; size it with the Limits buffer functions"
290            ),
291            #[cfg(feature = "std")]
292            Self::Io { kind } => write!(f, "I/O failure: {kind}"),
293            #[cfg(feature = "std")]
294            Self::Random => f.write_str(
295                "the operating system's random source failed; no predictable fallback is used",
296            ),
297            #[cfg(feature = "fs")]
298            Self::Store { reason } => write!(f, "storage: {}", reason.describe()),
299            Self::Poisoned => {
300                f.write_str("parser used after an error; call reset before parsing again")
301            }
302        }
303    }
304}
305
306impl core::error::Error for FormError {}
307
308/// Where an error was found.
309///
310/// # Examples
311///
312/// ```
313/// let err = form_net::parse_urlencoded(b"a=1&b=%").unwrap_err();
314/// let at = err.location().unwrap();
315/// assert_eq!((at.part, at.offset), (None, 6));
316/// ```
317#[non_exhaustive]
318#[derive(Clone, Copy, Debug, PartialEq, Eq)]
319pub struct Location {
320    /// The multipart part index (from 0), when the error is inside a part.
321    pub part: Option<u32>,
322    /// The byte offset in the body (or in the component, for `decode_into`).
323    pub offset: u64,
324}
325
326impl Location {
327    /// No location: configuration errors.
328    pub const NONE: Location = Location {
329        part: None,
330        offset: 0,
331    };
332
333    pub(crate) const fn at(offset: u64) -> Self {
334        Self { part: None, offset }
335    }
336
337    pub(crate) const fn in_part(part: Option<u32>, offset: u64) -> Self {
338        Self { part, offset }
339    }
340
341    const fn shifted(self, by: u64) -> Self {
342        Self {
343            part: self.part,
344            offset: self.offset.saturating_add(by),
345        }
346    }
347}
348
349impl fmt::Display for Location {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        match self.part {
352            Some(part) => write!(f, "part {part}, byte {}", self.offset),
353            None => write!(f, "byte {}", self.offset),
354        }
355    }
356}
357
358/// Which budget of [`Limits`](crate::Limits) was exceeded.
359#[non_exhaustive]
360#[derive(Clone, Copy, Debug, PartialEq, Eq)]
361pub enum Limit {
362    /// `max_body_bytes`.
363    Body,
364    /// `max_fields`.
365    Fields,
366    /// `max_name_bytes`.
367    NameLength,
368    /// `max_value_bytes`.
369    ValueLength,
370    /// `max_text_bytes`.
371    TextTotal,
372    /// `max_parts`.
373    Parts,
374    /// `max_files`.
375    Files,
376    /// `max_file_bytes`.
377    FileSize,
378    /// `max_part_header_bytes`.
379    PartHeaderBytes,
380    /// `max_part_headers`.
381    PartHeaderCount,
382    /// `max_filename_bytes`.
383    FilenameLength,
384    /// `max_preamble_bytes`.
385    Preamble,
386    /// `max_epilogue_bytes`.
387    Epilogue,
388    /// `max_padding_bytes`.
389    Padding,
390    /// `max_token_bytes`.
391    TokenLength,
392    /// The per-request disk limit of a store.
393    Disk,
394}
395
396impl Limit {
397    const fn code(self) -> &'static str {
398        match self {
399            Self::Body => "limit.body",
400            Self::Fields => "limit.fields",
401            Self::NameLength => "limit.name_length",
402            Self::ValueLength => "limit.value_length",
403            Self::TextTotal => "limit.text_total",
404            Self::Parts => "limit.parts",
405            Self::Files => "limit.files",
406            Self::FileSize => "limit.file_size",
407            Self::PartHeaderBytes => "limit.part_header_bytes",
408            Self::PartHeaderCount => "limit.part_header_count",
409            Self::FilenameLength => "limit.filename_length",
410            Self::Preamble => "limit.preamble",
411            Self::Epilogue => "limit.epilogue",
412            Self::Padding => "limit.padding",
413            Self::TokenLength => "limit.token_length",
414            Self::Disk => "limit.disk",
415        }
416    }
417
418    const fn field_name(self) -> &'static str {
419        match self {
420            Self::Body => "max_body_bytes",
421            Self::Fields => "max_fields",
422            Self::NameLength => "max_name_bytes",
423            Self::ValueLength => "max_value_bytes",
424            Self::TextTotal => "max_text_bytes",
425            Self::Parts => "max_parts",
426            Self::Files => "max_files",
427            Self::FileSize => "max_file_bytes",
428            Self::PartHeaderBytes => "max_part_header_bytes",
429            Self::PartHeaderCount => "max_part_headers",
430            Self::FilenameLength => "max_filename_bytes",
431            Self::Preamble => "max_preamble_bytes",
432            Self::Epilogue => "max_epilogue_bytes",
433            Self::Padding => "max_padding_bytes",
434            Self::TokenLength => "max_token_bytes",
435            Self::Disk => "max_disk_bytes",
436        }
437    }
438
439    const fn describe(self) -> &'static str {
440        match self {
441            Self::Body => "body too large",
442            Self::Fields => "too many fields",
443            Self::NameLength => "field name too long",
444            Self::ValueLength => "field value too long",
445            Self::TextTotal => "text fields too large in total",
446            Self::Parts => "too many parts",
447            Self::Files => "too many files",
448            Self::FileSize => "file too large",
449            Self::PartHeaderBytes => "part headers too large",
450            Self::PartHeaderCount => "too many part header lines",
451            Self::FilenameLength => "file name too long",
452            Self::Preamble => "too many bytes before the first boundary",
453            Self::Epilogue => "too many bytes after the last boundary",
454            Self::Padding => "too much padding after a boundary",
455            Self::TokenLength => "anti-forgery token too long",
456            Self::Disk => "too many bytes stored on disk for one request",
457        }
458    }
459
460    const fn action(self) -> &'static str {
461        match self {
462            Self::Body => "raise max_body_bytes if this route accepts larger bodies",
463            Self::Fields => "raise max_fields if this route accepts more fields",
464            Self::NameLength => "raise max_name_bytes if this route uses longer names",
465            Self::ValueLength => "raise max_value_bytes, or use a file input for large text",
466            Self::TextTotal => "raise max_text_bytes if this route accepts more text",
467            Self::Parts => "raise max_parts if this route accepts more parts",
468            Self::Files => "raise max_files if this route accepts more files",
469            Self::FileSize => "raise max_file_bytes if this route accepts larger files",
470            Self::PartHeaderBytes => "raise max_part_header_bytes if real clients need it",
471            Self::PartHeaderCount => "raise max_part_headers if real clients need it",
472            Self::FilenameLength => "raise max_filename_bytes if real clients need it",
473            Self::Preamble | Self::Epilogue => "the boundary is probably wrong",
474            Self::Padding => "the body is not what browsers send",
475            Self::TokenLength => "treat the request as forged",
476            Self::Disk => "raise the store's max_disk_bytes if this route accepts more",
477        }
478    }
479}
480
481/// What part of the grammar the input broke.
482#[non_exhaustive]
483#[derive(Clone, Copy, Debug, PartialEq, Eq)]
484pub enum SyntaxError {
485    /// A `Content-Type` value (of the request or of a part) is malformed.
486    InvalidContentType,
487    /// `multipart/form-data` without a `boundary` parameter.
488    MissingBoundary,
489    /// Two `boundary` parameters.
490    DuplicateBoundary,
491    /// A boundary that is empty, longer than 70 bytes, has a byte outside
492    /// RFC 2046 `bchars`, or ends with a space.
493    InvalidBoundary,
494    /// The body ended without any delimiter.
495    NoInitialDelimiter,
496    /// A full delimiter followed by something other than `--`, padding, or
497    /// CRLF; `--boundary` after a lone CR or LF, or as the first bytes of a
498    /// part's data (D-113); or writer data that contains any of these.
499    BoundaryInContent,
500    /// LF without CR where CRLF is required.
501    BareLineFeed,
502    /// A part header line without a colon.
503    MalformedHeaderLine,
504    /// A folded part header line.
505    HeaderFolding,
506    /// A part header name that is not a token, or has whitespace before the
507    /// colon.
508    InvalidHeaderName,
509    /// A control byte other than HTAB in a part header value.
510    ControlInHeader,
511    /// A part without `Content-Disposition`.
512    MissingContentDisposition,
513    /// A known part header given twice.
514    DuplicateHeader,
515    /// A `Content-Disposition` type other than `form-data`.
516    NotFormData,
517    /// A `Content-Disposition` without `name`.
518    MissingName,
519    /// An empty `name`.
520    EmptyName,
521    /// A parameter given twice.
522    DuplicateParameter,
523    /// Malformed parameters (or more than 16 on one header).
524    MalformedParameter,
525    /// A quoted parameter value that contains `\"`.
526    AmbiguousQuote,
527    /// The body ended before the close delimiter.
528    Truncated,
529}
530
531impl SyntaxError {
532    const fn code(self) -> &'static str {
533        match self {
534            Self::InvalidContentType => "syntax.invalid_content_type",
535            Self::MissingBoundary => "syntax.missing_boundary",
536            Self::DuplicateBoundary => "syntax.duplicate_boundary",
537            Self::InvalidBoundary => "syntax.invalid_boundary",
538            Self::NoInitialDelimiter => "syntax.no_initial_delimiter",
539            Self::BoundaryInContent => "syntax.boundary_in_content",
540            Self::BareLineFeed => "syntax.bare_line_feed",
541            Self::MalformedHeaderLine => "syntax.malformed_header_line",
542            Self::HeaderFolding => "syntax.header_folding",
543            Self::InvalidHeaderName => "syntax.invalid_header_name",
544            Self::ControlInHeader => "syntax.control_in_header",
545            Self::MissingContentDisposition => "syntax.missing_content_disposition",
546            Self::DuplicateHeader => "syntax.duplicate_header",
547            Self::NotFormData => "syntax.not_form_data",
548            Self::MissingName => "syntax.missing_name",
549            Self::EmptyName => "syntax.empty_name",
550            Self::DuplicateParameter => "syntax.duplicate_parameter",
551            Self::MalformedParameter => "syntax.malformed_parameter",
552            Self::AmbiguousQuote => "syntax.ambiguous_quote",
553            Self::Truncated => "syntax.truncated",
554        }
555    }
556
557    const fn describe(self) -> &'static str {
558        match self {
559            Self::InvalidContentType => "malformed Content-Type",
560            Self::MissingBoundary => "multipart/form-data without a boundary",
561            Self::DuplicateBoundary => "two boundary parameters",
562            Self::InvalidBoundary => "invalid multipart boundary",
563            Self::NoInitialDelimiter => {
564                "no multipart delimiter found: the body is not multipart or the boundary is wrong"
565            }
566            Self::BoundaryInContent => "the boundary appears inside a part",
567            Self::BareLineFeed => "LF without CR where CRLF is required",
568            Self::MalformedHeaderLine => "malformed part header line",
569            Self::HeaderFolding => "folded part header line",
570            Self::InvalidHeaderName => "invalid part header name",
571            Self::ControlInHeader => "control byte in a part header",
572            Self::MissingContentDisposition => "part without Content-Disposition",
573            Self::DuplicateHeader => "part header given twice",
574            Self::NotFormData => "Content-Disposition type is not form-data",
575            Self::MissingName => "part without a name",
576            Self::EmptyName => "part with an empty name",
577            Self::DuplicateParameter => "parameter given twice",
578            Self::MalformedParameter => "malformed header parameter",
579            Self::AmbiguousQuote => "quoted value contains \\\"",
580            Self::Truncated => "body ended before the closing boundary",
581        }
582    }
583}
584
585/// Bytes that do not decode.
586#[non_exhaustive]
587#[derive(Clone, Copy, Debug, PartialEq, Eq)]
588pub enum EncodingError {
589    /// `%` not followed by two hex digits, under `PercentPolicy::Refuse`.
590    InvalidPercent,
591    /// Text that is not valid UTF-8.
592    InvalidUtf8,
593    /// A charset other than UTF-8 or US-ASCII.
594    UnsupportedCharset,
595    /// A malformed `filename*`.
596    InvalidExtendedValue,
597}
598
599impl EncodingError {
600    const fn code(self) -> &'static str {
601        match self {
602            Self::InvalidPercent => "encoding.invalid_percent",
603            Self::InvalidUtf8 => "encoding.invalid_utf8",
604            Self::UnsupportedCharset => "encoding.unsupported_charset",
605            Self::InvalidExtendedValue => "encoding.invalid_extended_value",
606        }
607    }
608
609    const fn describe(self) -> &'static str {
610        match self {
611            Self::InvalidPercent => "invalid percent escape",
612            Self::InvalidUtf8 => "text is not valid UTF-8",
613            Self::UnsupportedCharset => "unsupported charset: only UTF-8 and US-ASCII are accepted",
614            Self::InvalidExtendedValue => "malformed filename*",
615        }
616    }
617}
618
619/// Well-formed input that a [`Policy`](crate::Policy) refuses.
620#[non_exhaustive]
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622pub enum PolicyRefusal {
623    /// A `Content-Transfer-Encoding` other than `7bit`, `8bit`, `binary`.
624    TransferEncoding,
625    /// A part declared `multipart/*`.
626    NestedMultipart,
627    /// An unknown part header under `HeaderPolicy::Refuse`.
628    UnknownHeader,
629    /// Both `filename` and `filename*` in one part.
630    BothFilenames,
631    /// `filename*` under `ExtendedFilenamePolicy::Refuse`.
632    ExtendedFilename,
633    /// A file part before the token field under
634    /// `TokenPlacement::BeforeFirstFile`.
635    TokenFieldLate,
636    /// The token field given twice.
637    DuplicateToken,
638}
639
640impl PolicyRefusal {
641    const fn code(self) -> &'static str {
642        match self {
643            Self::TransferEncoding => "policy.transfer_encoding",
644            Self::NestedMultipart => "policy.nested_multipart",
645            Self::UnknownHeader => "policy.unknown_header",
646            Self::BothFilenames => "policy.both_filenames",
647            Self::ExtendedFilename => "policy.extended_filename",
648            Self::TokenFieldLate => "policy.token_field_late",
649            Self::DuplicateToken => "policy.duplicate_token",
650        }
651    }
652
653    const fn describe(self) -> &'static str {
654        match self {
655            Self::TransferEncoding => "Content-Transfer-Encoding other than identity",
656            Self::NestedMultipart => "nested multipart part",
657            Self::UnknownHeader => "unknown part header",
658            Self::BothFilenames => "both filename and filename*",
659            Self::ExtendedFilename => "filename* is not accepted",
660            Self::TokenFieldLate => {
661                "file part before the anti-forgery token; treat as a possible forgery"
662            }
663            Self::DuplicateToken => "anti-forgery token given twice; treat as a possible forgery",
664        }
665    }
666}
667
668/// Why typed extraction failed.
669#[non_exhaustive]
670#[derive(Clone, Copy, Debug, PartialEq, Eq)]
671pub enum ExtractError {
672    /// A name the struct neither declares nor ignores.
673    Unknown,
674    /// A required field is absent.
675    Missing,
676    /// A single-valued field was given twice.
677    Duplicate,
678    /// A value that does not convert; the text says why.
679    Invalid(&'static str),
680    /// A file where text was expected, or the reverse.
681    WrongKind,
682}
683
684impl ExtractError {
685    const fn code(self) -> &'static str {
686        match self {
687            Self::Unknown => "extract.unknown",
688            Self::Missing => "extract.missing",
689            Self::Duplicate => "extract.duplicate",
690            Self::Invalid(_) => "extract.invalid",
691            Self::WrongKind => "extract.wrong_kind",
692        }
693    }
694
695    const fn describe(self) -> &'static str {
696        match self {
697            Self::Unknown => "unknown field",
698            Self::Missing => "missing field",
699            Self::Duplicate => "field given more than once",
700            Self::Invalid(why) => why,
701            Self::WrongKind => "file where text was expected, or text where a file was expected",
702        }
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use alloc::string::ToString;
710
711    #[test]
712    fn test_status_hint_every_family_maps() {
713        let at = Location::NONE;
714        assert_eq!(FormError::limit(Limit::Parts, at).status_hint(), 413);
715        assert_eq!(
716            FormError::syntax(SyntaxError::Truncated, at).status_hint(),
717            400
718        );
719        assert_eq!(
720            FormError::encoding(EncodingError::UnsupportedCharset, at).status_hint(),
721            415
722        );
723        assert_eq!(
724            FormError::encoding(EncodingError::InvalidUtf8, at).status_hint(),
725            400
726        );
727        assert_eq!(
728            FormError::policy(PolicyRefusal::NestedMultipart, at).status_hint(),
729            415
730        );
731        assert_eq!(
732            FormError::policy(PolicyRefusal::TokenFieldLate, at).status_hint(),
733            400
734        );
735        let extract = FormError::Extract {
736            reason: ExtractError::Missing,
737            field: Some("email"),
738        };
739        assert_eq!(extract.status_hint(), 422);
740        assert_eq!(
741            FormError::InvalidLimits { limit: Limit::Body }.status_hint(),
742            500
743        );
744        assert_eq!(FormError::BufferTooSmall { needed: 3 }.status_hint(), 500);
745    }
746
747    #[test]
748    fn test_status_hint_refusal_out_of_range_reports_400() {
749        let at = Location::NONE;
750        let ok = FormError::Refused {
751            refusal: Refusal::new(422, "x"),
752            at,
753        };
754        assert_eq!(ok.status_hint(), 422);
755        for status in [0, 200, 399, 600, 999] {
756            let e = FormError::Refused {
757                refusal: Refusal::new(status, "x"),
758                at,
759            };
760            assert_eq!(e.status_hint(), 400);
761        }
762    }
763
764    #[test]
765    fn test_code_refusal_returns_inspector_code() {
766        let e = FormError::Refused {
767            refusal: Refusal::new(422, "customs.unexpected_field"),
768            at: Location::NONE,
769        };
770        assert_eq!(e.code(), "customs.unexpected_field");
771    }
772
773    #[test]
774    fn test_display_never_contains_the_field_value() {
775        let e = FormError::Extract {
776            reason: ExtractError::Invalid("not a number"),
777            field: Some("age"),
778        };
779        assert_eq!(e.to_string(), "not a number (field `age`)");
780        let e = FormError::limit(Limit::Parts, Location::in_part(Some(3), 99));
781        assert_eq!(
782            e.to_string(),
783            "too many parts (part 3, byte 99): raise max_parts if this route accepts more parts"
784        );
785    }
786
787    #[test]
788    fn test_shifted_moves_input_errors_only() {
789        let e = FormError::encoding(EncodingError::InvalidPercent, Location::at(2)).shifted(10);
790        assert_eq!(e.location().map(|l| l.offset), Some(12));
791        assert_eq!(FormError::Poisoned.shifted(10), FormError::Poisoned);
792    }
793
794    #[test]
795    fn test_form_error_is_small_and_copy() {
796        fn is_copy<T: Copy>() {}
797        is_copy::<FormError>();
798        assert!(core::mem::size_of::<FormError>() <= 48);
799    }
800}