Skip to main content

sofie_core/encoding/
percent.rs

1//! Percent-encoding (RFC 3986, section 2.1), with uppercase digits as it recommends.
2
3use super::hex;
4
5const DIGITS: &[u8; 16] = b"0123456789ABCDEF";
6
7/// RFC 3986, section 2.3: the bytes that never need escaping.
8#[must_use]
9pub fn is_unreserved(byte: u8) -> bool {
10    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
11}
12
13#[must_use]
14pub fn encode(text: &str, keep: impl Fn(u8) -> bool) -> String {
15    let mut out = String::with_capacity(text.len());
16    push_encoded(&mut out, text, keep);
17    out
18}
19
20/// Every byte `keep` refuses as `%XX`; bytes outside ASCII always are.
21pub fn push_encoded(out: &mut String, text: &str, keep: impl Fn(u8) -> bool) {
22    push(out, text, keep, Space::Escaped);
23}
24
25/// `application/x-www-form-urlencoded` (WHATWG URL, section 5.2): `name=value` pairs joined by `&`.
26#[must_use]
27pub fn form<'a>(fields: impl IntoIterator<Item = (&'a str, &'a str)>) -> String {
28    let mut out = String::new();
29    for (name, value) in fields {
30        if !out.is_empty() {
31            out.push('&');
32        }
33        push_form_encoded(&mut out, name);
34        out.push('=');
35        push_form_encoded(&mut out, value);
36    }
37    out
38}
39
40/// One form name or value: a space as `+`, and only `*-._` kept beside letters and digits.
41pub fn push_form_encoded(out: &mut String, text: &str) {
42    push(out, text, is_form_safe, Space::Plus);
43}
44
45/// `+` stays `+`, as in a path. `None` when a `%` is not followed by two hex digits.
46#[must_use]
47pub fn decode(encoded: &[u8]) -> Option<Vec<u8>> {
48    unescape(encoded, Space::Escaped)
49}
50
51/// `+` is a space, as in a form. `None` when a `%` is not followed by two hex digits.
52#[must_use]
53pub fn decode_form(encoded: &[u8]) -> Option<Vec<u8>> {
54    unescape(encoded, Space::Plus)
55}
56
57#[derive(Clone, Copy, PartialEq, Eq)]
58enum Space {
59    Escaped,
60    Plus,
61}
62
63fn is_form_safe(byte: u8) -> bool {
64    byte.is_ascii_alphanumeric() || matches!(byte, b'*' | b'-' | b'.' | b'_')
65}
66
67fn push(out: &mut String, text: &str, keep: impl Fn(u8) -> bool, space: Space) {
68    let mut copied = 0;
69    for (index, byte) in text.bytes().enumerate() {
70        if byte.is_ascii() && keep(byte) {
71            continue;
72        }
73        // A kept run is ASCII, so it starts and ends on a character boundary; between two
74        // escaped bytes of one character, `copied` does not, and slicing there panics.
75        if copied < index {
76            out.push_str(&text[copied..index]);
77        }
78        if space == Space::Plus && byte == b' ' {
79            out.push('+');
80        } else {
81            out.push('%');
82            out.push(char::from(DIGITS[usize::from(byte >> 4)]));
83            out.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
84        }
85        copied = index + 1;
86    }
87    out.push_str(&text[copied..]);
88}
89
90fn unescape(encoded: &[u8], space: Space) -> Option<Vec<u8>> {
91    let mut bytes = Vec::with_capacity(encoded.len());
92    let mut rest = encoded;
93    while let Some((&byte, tail)) = rest.split_first() {
94        rest = tail;
95        match byte {
96            b'+' if space == Space::Plus => bytes.push(b' '),
97            b'%' => {
98                let ([high, low], tail) = rest.split_first_chunk()?;
99                bytes.push(hex::byte_of(*high, *low)?);
100                rest = tail;
101            }
102            _ => bytes.push(byte),
103        }
104    }
105    Some(bytes)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn percent_encodes_all_but_unreserved_characters() {
114        let mut out = String::new();
115        push_encoded(&mut out, "a-Z_9.~ /?#%é", is_unreserved);
116        assert_eq!(out, "a-Z_9.~%20%2F%3F%23%25%C3%A9");
117    }
118
119    #[test]
120    fn keep_decides_which_ascii_bytes_stay_but_never_keeps_other_bytes() {
121        assert_eq!(encode("", is_unreserved), "");
122        assert_eq!(encode("a/b c", |byte| byte == b'/'), "%61/%62%20%63");
123        assert_eq!(encode("é/", |_| true), "%C3%A9/");
124        assert_eq!(encode("ab", |_| false), "%61%62");
125    }
126
127    #[test]
128    fn a_form_field_is_percent_encoded_with_spaces_as_plus() {
129        let mut out = String::new();
130        push_form_encoded(&mut out, "line_items[0][price]=a b&ç");
131        assert_eq!(out, "line_items%5B0%5D%5Bprice%5D%3Da+b%26%C3%A7");
132        assert_eq!(encode("*~+", is_form_safe), "*%7E%2B");
133    }
134
135    #[test]
136    fn a_form_joins_its_fields() {
137        assert_eq!(form([]), "");
138        assert_eq!(form([("a", "1 2"), ("b&", "")]), "a=1+2&b%26=");
139    }
140
141    #[test]
142    fn decodes_either_case_and_plus_only_in_a_form() {
143        assert_eq!(decode(b"caf%C3%a9+bar").unwrap(), "café+bar".as_bytes());
144        assert_eq!(
145            decode_form(b"caf%C3%a9+bar").unwrap(),
146            "café bar".as_bytes()
147        );
148        assert_eq!(decode(b"").unwrap(), b"");
149        assert_eq!(decode(b"%2B").unwrap(), b"+");
150    }
151
152    #[test]
153    fn refuses_a_percent_without_two_hex_digits() {
154        for encoded in [&b"50%"[..], b"%2", b"%zz", b"%+f", b"a%-1b"] {
155            assert_eq!(decode(encoded), None, "{encoded:?}");
156            assert_eq!(decode_form(encoded), None, "{encoded:?}");
157        }
158    }
159
160    #[test]
161    fn decoding_undoes_encoding() {
162        let text = "/orders?page=2 & Jürgen+1@x.io";
163        assert_eq!(
164            decode(encode(text, is_unreserved).as_bytes()).unwrap(),
165            text.as_bytes()
166        );
167        assert_eq!(
168            decode_form(form([(text, text)]).as_bytes()).unwrap(),
169            format!("{text}={text}").as_bytes()
170        );
171    }
172}