7.0 KiB · text History b85db9e
//! `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"));
    }
}