//! `application/x-www-form-urlencoded` request-body parsing.
//!
//! Pure logic with no ESP dependency, so it builds and tests on the host.
//! Extracted from `nfc-522`'s `provisioning/http.rs`, where the same decoding
//! was inlined into a single fixed-shape `parse_config`.
use std::collections::BTreeMap;
use std::str::FromStr;
/// A decoded form body.
///
/// Duplicate keys keep the **last** occurrence, matching how browsers and most
/// server frameworks treat repeated fields.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Form(BTreeMap<String, String>);
impl Form {
/// Parse a urlencoded body. Malformed pairs (no `=`) are skipped.
pub fn parse(body: &str) -> Self {
let mut map = BTreeMap::new();
for pair in body.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
map.insert(url_decode(key), url_decode(value));
}
Self(map)
}
/// Raw value for `key`, if the field was present.
pub fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).map(String::as_str)
}
/// Value for `key`, or `""` when absent.
pub fn str(&self, key: &str) -> &str {
self.get(key).unwrap_or("")
}
/// Trimmed value, or `None` when absent or empty after trimming.
///
/// Use for optional credentials — an empty text input submits as `key=`,
/// which should mean "unset", not `Some("")`.
pub fn non_empty(&self, key: &str) -> Option<String> {
let v = self.get(key)?.trim();
(!v.is_empty()).then(|| v.to_string())
}
/// Trimmed value, falling back to `default` when absent or empty.
pub fn trimmed_or(&self, key: &str, default: &str) -> String {
self.non_empty(key).unwrap_or_else(|| default.to_string())
}
/// Parse the trimmed value as `T`, returning `None` if absent or invalid.
pub fn parsed<T: FromStr>(&self, key: &str) -> Option<T> {
self.get(key)?.trim().parse().ok()
}
/// Parse the trimmed value as `T`, falling back to `default`.
pub fn parsed_or<T: FromStr>(&self, key: &str, default: T) -> T {
self.parsed(key).unwrap_or(default)
}
/// Read a checkbox.
///
/// An unchecked checkbox is **omitted entirely** from the body, so absence
/// means `false`. A present field counts as `true` only for the usual
/// affirmative spellings, so `foo=off` reads as `false` rather than as
/// "present, therefore on".
pub fn checkbox(&self, key: &str) -> bool {
matches!(self.get(key), Some("on" | "true" | "1" | "yes"))
}
/// Whether the field was present at all (checked or not).
pub fn contains(&self, key: &str) -> bool {
self.0.contains_key(key)
}
/// Number of decoded fields.
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Iterate over the decoded pairs, in key order.
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
}
/// Decode a urlencoded component (`+` -> space, `%XX` -> byte).
///
/// Invalid escapes are passed through literally rather than rejected; a config
/// form is not a place to fail a whole request over one bad byte. Non-UTF-8
/// results are lossily replaced.
pub fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_plus_and_percent() {
assert_eq!(url_decode("hello+world"), "hello world");
assert_eq!(url_decode("a%20b"), "a b");
assert_eq!(url_decode("%41%42%43"), "ABC");
}
#[test]
fn passes_through_invalid_escapes() {
// Truncated escape at end of input.
assert_eq!(url_decode("100%"), "100%");
assert_eq!(url_decode("50%2"), "50%2");
// Non-hex digits.
assert_eq!(url_decode("%zz"), "%zz");
}
#[test]
fn decodes_utf8_multibyte() {
// "é" is C3 A9.
assert_eq!(url_decode("caf%C3%A9"), "café");
}
#[test]
fn parses_pairs_and_decodes_keys() {
let f = Form::parse("wifi_ssid=my+net&wifi_pass=p%40ss");
assert_eq!(f.get("wifi_ssid"), Some("my net"));
assert_eq!(f.get("wifi_pass"), Some("p@ss"));
assert_eq!(f.len(), 2);
}
#[test]
fn skips_malformed_pairs() {
let f = Form::parse("a=1&garbage&b=2");
assert_eq!(f.len(), 2);
assert_eq!(f.get("a"), Some("1"));
assert_eq!(f.get("b"), Some("2"));
}
#[test]
fn last_duplicate_wins() {
let f = Form::parse("a=1&a=2");
assert_eq!(f.get("a"), Some("2"));
}
#[test]
fn empty_value_is_not_non_empty() {
let f = Form::parse("user=&pass=+++");
assert!(f.contains("user"));
assert_eq!(f.str("user"), "");
assert_eq!(f.non_empty("user"), None);
// Whitespace-only trims to empty.
assert_eq!(f.non_empty("pass"), None);
}
#[test]
fn trimmed_or_falls_back() {
let f = Form::parse("root=+neiam+&blank=");
assert_eq!(f.trimmed_or("root", "def"), "neiam");
assert_eq!(f.trimmed_or("blank", "def"), "def");
assert_eq!(f.trimmed_or("missing", "def"), "def");
}
#[test]
fn parses_numbers() {
let f = Form::parse("port=1883&bad=abc");
assert_eq!(f.parsed::<u16>("port"), Some(1883));
assert_eq!(f.parsed::<u16>("bad"), None);
assert_eq!(f.parsed_or::<u16>("bad", 1883), 1883);
assert_eq!(f.parsed_or::<u16>("missing", 1883), 1883);
// Out of range for the target type.
assert_eq!(Form::parse("p=70000").parsed::<u16>("p"), None);
}
#[test]
fn checkbox_absent_is_false() {
// The whole point: browsers omit unchecked boxes.
let f = Form::parse("other=1");
assert!(!f.checkbox("tls"));
}
#[test]
fn checkbox_present_forms() {
assert!(Form::parse("tls=on").checkbox("tls"));
assert!(Form::parse("tls=true").checkbox("tls"));
assert!(Form::parse("tls=1").checkbox("tls"));
// Explicit negative spellings are honoured, not treated as "present".
assert!(!Form::parse("tls=off").checkbox("tls"));
assert!(!Form::parse("tls=").checkbox("tls"));
}
}
neiam
/neiam-portal
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
7.0 KiB · text
History
b85db9e