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}