Skip to main content

base_web_content/
field_value.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use std::fmt;
5
6/// A field's value converted from its submitted text, the conversion trait for scalar fields.
7///
8/// `String`, the integer types, `f32`, `f64`, and `bool` implement it, as does an enumeration with `#[derive(FieldValue)]`. Implement it for a type of your own to accept that type as a field, with the text converted by your code: a failure is an `invalid_type` violation, like any other.
9///
10/// The text is valid UTF-8, whatever character set the part declared, since text in another character set is refused before it is converted.
11///
12/// ```
13/// use base_web_content::{FieldValue, FieldValueError};
14///
15/// /// A colour written as `#rrggbb`.
16/// struct Colour(u32);
17///
18/// impl FieldValue for Colour {
19///     fn from_field_text(text: &str) -> Result<Self, FieldValueError> {
20///         let digits = text.strip_prefix('#').filter(|digits| digits.len() == 6 && digits.bytes().all(|byte| byte.is_ascii_hexdigit()));
21///         let digits = digits.ok_or(FieldValueError)?;
22///         u32::from_str_radix(digits, 16).map(Colour).map_err(|_| FieldValueError)
23///     }
24/// }
25///
26/// assert_eq!(Colour::from_field_text("#ff8000").map(|colour| colour.0), Ok(0xff8000));
27/// assert!(Colour::from_field_text("orange").is_err());
28/// ```
29#[diagnostic::on_unimplemented(
30    message = "`{Self}` cannot be converted from a field's text",
31    note = "implement `FieldValue` for it, or derive it for an enumeration"
32)]
33pub trait FieldValue: Sized + 'static {
34    /// The value the text spells.
35    ///
36    /// # Errors
37    ///
38    /// [`FieldValueError`] when the text does not spell a value of this type.
39    fn from_field_text(text: &str) -> Result<Self, FieldValueError>;
40}
41
42/// Why a field's text could not be converted to its type. It carries no detail, since the text may be a password or personal data that must not reach logs.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
44pub struct FieldValueError;
45
46impl fmt::Display for FieldValueError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        formatter.write_str("invalid field value")
49    }
50}
51
52impl std::error::Error for FieldValueError {}
53
54// SECURITY: Text may contain any character UTF-8 allows, including NUL, CR, LF, and bidirectional controls, sent raw in multipart content or escaped in URL-encoded content. This is not an injection weakness here: the parser interprets none of it, and an application that writes a value into another syntax, such as a header, a log line, or a path, must encode or check it for that syntax.
55/// Any text, unchanged.
56impl FieldValue for String {
57    fn from_field_text(text: &str) -> Result<Self, FieldValueError> {
58        Ok(text.to_owned())
59    }
60}
61
62/// Exactly `true` or `false`.
63impl FieldValue for bool {
64    fn from_field_text(text: &str) -> Result<Self, FieldValueError> {
65        match text {
66            "true" => Ok(true),
67            "false" => Ok(false),
68            _ => Err(FieldValueError),
69        }
70    }
71}
72
73/// The digits of an integer: an optional sign followed by one or more ASCII decimal digits, as `(negative, digits)`.
74fn integer_digits(text: &str) -> Option<(bool, &str)> {
75    let (negative, digits) = match text.as_bytes().first() {
76        Some(b'-') => (true, &text[1..]),
77        Some(b'+') => (false, &text[1..]),
78        _ => (false, text),
79    };
80    (!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
81        .then_some((negative, digits))
82}
83
84macro_rules! signed_field_values {
85    ($($type:ty),*) => {$(
86        /// An optional `+` or `-` followed by one or more ASCII decimal digits, within the type's range.
87        impl FieldValue for $type {
88            fn from_field_text(text: &str) -> Result<Self, FieldValueError> {
89                // The standard library accepts exactly this syntax for signed integers, once the text is known not to be a bare sign.
90                integer_digits(text).ok_or(FieldValueError)?;
91                text.parse().map_err(|_| FieldValueError)
92            }
93        }
94    )*};
95}
96
97macro_rules! unsigned_field_values {
98    ($($type:ty),*) => {$(
99        /// An optional `+` or `-` followed by one or more ASCII decimal digits, within the type's range, so `-0` is zero.
100        impl FieldValue for $type {
101            fn from_field_text(text: &str) -> Result<Self, FieldValueError> {
102                let (negative, digits) = integer_digits(text).ok_or(FieldValueError)?;
103                // A negative value is out of range unless it is zero, which the standard library would refuse for its sign.
104                if negative {
105                    return if digits.bytes().all(|byte| byte == b'0') { Ok(0) } else { Err(FieldValueError) };
106                }
107                digits.parse().map_err(|_| FieldValueError)
108            }
109        }
110    )*};
111}
112
113signed_field_values!(i8, i16, i32, i64, i128, isize);
114unsigned_field_values!(u8, u16, u32, u64, u128, usize);
115
116/// Whether `text` is a decimal number: an optional sign, digits with an optional fraction, or a fraction alone, and an optional exponent, such as `-1.5`, `.5`, `5.`, or `1e3`.
117fn is_decimal(text: &str) -> bool {
118    let bytes = text.as_bytes();
119    let mut index = usize::from(matches!(bytes.first(), Some(b'+' | b'-')));
120    let digits = |index: &mut usize| {
121        let start = *index;
122        while bytes.get(*index).is_some_and(u8::is_ascii_digit) {
123            *index += 1;
124        }
125        *index - start
126    };
127    let mut mantissa = digits(&mut index);
128    if bytes.get(index) == Some(&b'.') {
129        index += 1;
130        mantissa += digits(&mut index);
131    }
132    if mantissa == 0 {
133        return false;
134    }
135    if matches!(bytes.get(index), Some(b'e' | b'E')) {
136        index += 1;
137        index += usize::from(matches!(bytes.get(index), Some(b'+' | b'-')));
138        if digits(&mut index) == 0 {
139            return false;
140        }
141    }
142    index == bytes.len()
143}
144
145macro_rules! float_field_values {
146    ($($type:ty),*) => {$(
147        /// A finite decimal number with an optional sign, fraction, and exponent. Whitespace, `_` separators, hexadecimal, and `NaN` or infinity in any spelling, including a number too large for the type, are refused.
148        impl FieldValue for $type {
149            fn from_field_text(text: &str) -> Result<Self, FieldValueError> {
150                if !is_decimal(text) {
151                    return Err(FieldValueError);
152                }
153                // The syntax is a subset of what the standard library parses, so only the range can fail, as infinity.
154                let value: $type = text.parse().expect("a decimal number parses");
155                if value.is_finite() { Ok(value) } else { Err(FieldValueError) }
156            }
157        }
158    )*};
159}
160
161float_field_values!(f32, f64);
162
163/// The character set a part declared for its text: none, which means UTF-8 for a form, UTF-8, US-ASCII, or another, which is not supported.
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub enum Charset {
166    Unspecified,
167    Utf8,
168    UsAscii,
169    Unsupported,
170}
171
172impl Charset {
173    /// The character set named by a `charset` parameter, if there is one.
174    pub(crate) fn from_parameter(charset: Option<&str>) -> Self {
175        match charset {
176            None => Self::Unspecified,
177            Some(name) if name.eq_ignore_ascii_case("utf-8") => Self::Utf8,
178            Some(name) if name.eq_ignore_ascii_case("us-ascii") => Self::UsAscii,
179            Some(_) => Self::Unsupported,
180        }
181    }
182
183    /// The bytes as text in this character set, or `None` when they are not valid in it or it is not supported.
184    pub(crate) fn decode(self, bytes: Vec<u8>) -> Option<String> {
185        match self {
186            Self::Unsupported => None,
187            Self::UsAscii if !bytes.is_ascii() => None,
188            _ => String::from_utf8(bytes).ok(),
189        }
190    }
191}