Skip to main content

urly/
quoting.rs

1//! Percent-encoding with the same rules as Python's yarl.
2//!
3//! * [`quote`] encodes a literal value: every character that isn't allowed in the
4//!   component, including `%`, is percent-encoded.
5//! * [`requote`] normalizes an already-encoded value: valid `%XX` escapes are kept
6//!   (uppercased, or decoded when they encode an unreserved character), any other
7//!   disallowed character is percent-encoded.
8//! * [`unquote`] decodes a value. Escapes that don't form valid UTF-8 are kept as is.
9//!
10//! All functions borrow the input if it doesn't need to change.
11//!
12//! ```
13//! use urly::quoting::{Component, quote, requote, unquote};
14//!
15//! assert_eq!(quote("a b/100%", Component::Path), "a%20b/100%25");
16//! assert_eq!(requote("a b/100%25%7e", Component::Path), "a%20b/100%25~");
17//! assert_eq!(quote("a b&c=d", Component::QueryPart), "a+b%26c%3Dd");
18//! assert_eq!(unquote("a+b%26c", Component::QueryPart), "a b&c");
19//! assert_eq!(quote("a=b; c", Component::Opaque), "a%3Db%3B%20c");
20//! ```
21use std::borrow::Cow;
22
23use crate::chars::{ALLOWED, HEX_UPPER, NONE, QS, Set, UNRESERVED, pct_at, push_pct};
24
25/// The URL component a value belongs to; it selects the characters that are
26/// left unencoded.
27#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
28pub enum Component {
29    /// User name or password. `:` and `@` are encoded.
30    UserInfo,
31    /// Path. `/` is kept; requoting never decodes `%2F`.
32    Path,
33    /// Whole query string. `&`, `=`, `+` and `;` are kept; space is encoded as `+`.
34    Query,
35    /// Query key or value. `&`, `=`, `+` and `;` are encoded; space is encoded as `+`.
36    QueryPart,
37    /// Fragment.
38    Fragment,
39    /// A standalone value outside of a URL, such as a cookie name or value.
40    /// Only unreserved characters (`A-Z a-z 0-9 - . _ ~`) are kept.
41    Opaque,
42}
43
44struct Quoter {
45    safe: Set,
46    protected: Set,
47    qs: bool,
48}
49
50static USERINFO: Quoter = Quoter {
51    safe: ALLOWED.union(QS),
52    protected: NONE,
53    qs: false,
54};
55static PATH: Quoter = Quoter {
56    safe: ALLOWED.union(QS).union(Set::new(b"@:/+")),
57    protected: Set::new(b"/+"),
58    qs: false,
59};
60static QUERY: Quoter = Quoter {
61    safe: ALLOWED.union(Set::new(b"?/:@=+&;")),
62    protected: QS,
63    qs: true,
64};
65// a literal `+` in a query would be decoded as space
66static QUERY_LITERAL: Quoter = Quoter {
67    safe: QUERY.safe.without(b'+'),
68    protected: QS,
69    qs: true,
70};
71static QUERY_PART: Quoter = Quoter {
72    safe: ALLOWED.union(Set::new(b"?/:@")),
73    protected: NONE,
74    qs: true,
75};
76static FRAGMENT: Quoter = Quoter {
77    safe: ALLOWED.union(QS).union(Set::new(b"?/:@")),
78    protected: NONE,
79    qs: false,
80};
81static OPAQUE: Quoter = Quoter {
82    safe: UNRESERVED,
83    protected: NONE,
84    qs: false,
85};
86
87const PATH_SAFE: Set = Set::new(b"/%");
88
89impl Component {
90    fn quoter(self, requote: bool) -> &'static Quoter {
91        match self {
92            Component::UserInfo => &USERINFO,
93            Component::Path => &PATH,
94            Component::Query if requote => &QUERY,
95            Component::Query => &QUERY_LITERAL,
96            Component::QueryPart => &QUERY_PART,
97            Component::Fragment => &FRAGMENT,
98            Component::Opaque => &OPAQUE,
99        }
100    }
101}
102
103/// Percent-encodes a literal value.
104pub fn quote(src: &str, component: Component) -> Cow<'_, str> {
105    quote_with(src, component, false)
106}
107
108/// Normalizes the percent-encoding of an already-encoded value.
109pub fn requote(src: &str, component: Component) -> Cow<'_, str> {
110    quote_with(src, component, true)
111}
112
113/// Decodes a percent-encoded value.
114///
115/// For [`Component::QueryPart`] `+` is decoded as space. For [`Component::Query`]
116/// `+` is decoded as space too, but escapes of `&`, `=`, `+` and `;` are kept, so
117/// the result can still be split into pairs.
118pub fn unquote(src: &str, component: Component) -> Cow<'_, str> {
119    match component {
120        Component::Query => unquote_with(src, true, &QS),
121        Component::QueryPart => unquote_with(src, true, &NONE),
122        _ => unquote_with(src, false, &NONE),
123    }
124}
125
126fn quote_with(src: &str, component: Component, requote: bool) -> Cow<'_, str> {
127    let q = component.quoter(requote);
128    let bytes = src.as_bytes();
129    let is_safe = |b: u8| q.safe.contains(b);
130
131    // fast path, find the first byte that has to change
132    let mut i = 0;
133    while i < bytes.len() {
134        let b = bytes[i];
135        if is_safe(b) {
136            i += 1;
137            continue;
138        }
139        if requote
140            && let Some(v) = pct_at(bytes, i)
141            && !bytes[i + 1].is_ascii_lowercase()
142            && !bytes[i + 2].is_ascii_lowercase()
143            && (q.protected.contains(v) || !q.safe.contains(v))
144        {
145            i += 3;
146            continue;
147        }
148        break;
149    }
150    if i == bytes.len() {
151        return Cow::Borrowed(src);
152    }
153
154    let mut out = String::with_capacity(bytes.len() + 16);
155    out.push_str(&src[..i]);
156    while i < bytes.len() {
157        let b = bytes[i];
158        if is_safe(b) {
159            out.push(b as char);
160            i += 1;
161        } else if requote && let Some(v) = pct_at(bytes, i) {
162            if q.protected.contains(v) || !q.safe.contains(v) {
163                push_pct(&mut out, v);
164            } else {
165                out.push(v as char);
166            }
167            i += 3;
168        } else if q.qs && b == b' ' {
169            out.push('+');
170            i += 1;
171        } else {
172            push_pct(&mut out, b);
173            i += 1;
174        }
175    }
176    Cow::Owned(out)
177}
178
179/// Decodes `src`, keeping escapes of `ignore` characters encoded.
180pub(crate) fn unquote_with<'a>(src: &'a str, plus: bool, ignore: &Set) -> Cow<'a, str> {
181    let bytes = src.as_bytes();
182    let Some(start) = bytes
183        .iter()
184        .position(|b| *b == b'%' || (plus && *b == b'+'))
185    else {
186        return Cow::Borrowed(src);
187    };
188
189    // fast path, decode everything and validate once
190    let mut buf = Vec::with_capacity(bytes.len());
191    buf.extend_from_slice(&bytes[..start]);
192    let mut i = start;
193    while i < bytes.len() {
194        let b = bytes[i];
195        if let Some(v) = pct_at(bytes, i) {
196            if ignore.contains(v) {
197                buf.extend_from_slice(&[
198                    b'%',
199                    HEX_UPPER[usize::from(v >> 4)],
200                    HEX_UPPER[usize::from(v & 15)],
201                ]);
202            } else {
203                buf.push(v);
204            }
205            i += 3;
206        } else {
207            buf.push(if plus && b == b'+' { b' ' } else { b });
208            i += 1;
209        }
210    }
211    if simdutf8::basic::from_utf8(&buf).is_ok() {
212        // SAFETY: validated above
213        return Cow::Owned(unsafe { String::from_utf8_unchecked(buf) });
214    }
215    unquote_invalid(src, plus, ignore, start)
216}
217
218/// Decodes `src`, re-encoding escapes that don't form valid UTF-8.
219fn unquote_invalid<'a>(src: &'a str, plus: bool, ignore: &Set, start: usize) -> Cow<'a, str> {
220    let bytes = src.as_bytes();
221    let mut out = String::with_capacity(bytes.len());
222    out.push_str(&src[..start]);
223    let mut pending = Vec::new();
224    let mut i = start;
225    while i < bytes.len() {
226        let b = bytes[i];
227        if let Some(v) = pct_at(bytes, i) {
228            if ignore.contains(v) {
229                flush(&mut out, &mut pending);
230                push_pct(&mut out, v);
231            } else {
232                pending.push(v);
233            }
234            i += 3;
235            continue;
236        }
237        flush(&mut out, &mut pending);
238        if plus && b == b'+' {
239            out.push(' ');
240            i += 1;
241        } else {
242            // copy the whole literal char
243            let len = utf8_len(b);
244            out.push_str(&src[i..i + len]);
245            i += len;
246        }
247    }
248    flush(&mut out, &mut pending);
249    Cow::Owned(out)
250}
251
252const fn utf8_len(b: u8) -> usize {
253    match b {
254        0..0x80 => 1,
255        0xc0..0xe0 => 2,
256        0xe0..0xf0 => 3,
257        _ => 4,
258    }
259}
260
261/// Appends decoded bytes; sequences that are not valid UTF-8 are re-encoded.
262fn flush(out: &mut String, pending: &mut Vec<u8>) {
263    let mut rest = &pending[..];
264    while !rest.is_empty() {
265        match simdutf8::compat::from_utf8(rest) {
266            Ok(s) => {
267                out.push_str(s);
268                break;
269            }
270            Err(e) => {
271                let valid = e.valid_up_to();
272                // SAFETY: `rest[..valid]` is valid UTF-8
273                out.push_str(unsafe { std::str::from_utf8_unchecked(&rest[..valid]) });
274                let invalid = e.error_len().unwrap_or(rest.len() - valid);
275                for b in &rest[valid..valid + invalid] {
276                    push_pct(out, *b);
277                }
278                rest = &rest[valid + invalid..];
279            }
280        }
281    }
282    pending.clear();
283}
284
285/// Decodes a path, keeping `%2F` and `%25` encoded.
286pub(crate) fn unquote_path_safe(src: &str) -> Cow<'_, str> {
287    unquote_with(src, false, &PATH_SAFE)
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn quote_rules() {
296        assert_eq!(quote("", Component::Path), "");
297        assert_eq!(quote("/a b/ü", Component::Path), "/a%20b/%C3%BC");
298        assert_eq!(quote("%41", Component::Path), "%2541");
299        assert_eq!(quote("u:p@x", Component::UserInfo), "u%3Ap%40x");
300        assert_eq!(
301            quote("a+b=c&d;e", Component::QueryPart),
302            "a%2Bb%3Dc%26d%3Be"
303        );
304        assert_eq!(quote("a=b&c d", Component::Query), "a=b&c+d");
305        assert_eq!(quote("x#y", Component::Fragment), "x%23y");
306        assert_eq!(
307            quote("a=b; c,\"%~", Component::Opaque),
308            "a%3Db%3B%20c%2C%22%25~"
309        );
310        assert!(matches!(
311            quote("a-b_c.1~", Component::Opaque),
312            Cow::Borrowed(_)
313        ));
314    }
315
316    #[test]
317    fn requote_rules() {
318        assert!(matches!(
319            requote("/a%2Fb%20c", Component::Path),
320            Cow::Borrowed(_)
321        ));
322        assert_eq!(requote("%7e%41%2f%2b", Component::Path), "~A%2F%2B");
323        assert_eq!(requote("100%", Component::Path), "100%25");
324        assert_eq!(requote("%zz%4", Component::Path), "%25zz%254");
325        assert_eq!(requote("a=%3d&b", Component::Query), "a=%3D&b");
326        assert_eq!(requote("a b", Component::Query), "a+b");
327        assert_eq!(requote("%C3%BC", Component::Fragment), "%C3%BC");
328    }
329
330    #[test]
331    fn unquote_rules() {
332        assert!(matches!(unquote("abc", Component::Path), Cow::Borrowed(_)));
333        assert_eq!(unquote("%C3%BC%20x", Component::Path), "ü x");
334        assert_eq!(unquote("a+b", Component::Path), "a+b");
335        assert_eq!(unquote("a+b%2B", Component::QueryPart), "a b+");
336        assert_eq!(unquote("a+b%26c%3D", Component::Query), "a b%26c%3D");
337        assert_eq!(unquote("%FF%C3%BCx%C3", Component::Path), "%FFüx%C3");
338        assert_eq!(unquote("100%", Component::Path), "100%");
339        assert_eq!(unquote_path_safe("a%2Fb%25c%20"), "a%2Fb%25c ");
340        assert_eq!(unquote("ü%20", Component::Fragment), "ü ");
341    }
342}