sofie_core/encoding/
percent.rs1use super::hex;
4
5const DIGITS: &[u8; 16] = b"0123456789ABCDEF";
6
7#[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
20pub fn push_encoded(out: &mut String, text: &str, keep: impl Fn(u8) -> bool) {
22 push(out, text, keep, Space::Escaped);
23}
24
25#[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
40pub fn push_form_encoded(out: &mut String, text: &str) {
42 push(out, text, is_form_safe, Space::Plus);
43}
44
45#[must_use]
47pub fn decode(encoded: &[u8]) -> Option<Vec<u8>> {
48 unescape(encoded, Space::Escaped)
49}
50
51#[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 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}