Skip to main content

web_net/
url.rs

1//! Request paths: percent-decoding once, dot segments, the one canonical
2//! spelling, and the trailing-slash policy.
3//!
4//! Every path a route sees is canonical: decoded once and re-encoded in one
5//! way, with no dot segments and no empty segments. A request whose path is
6//! spelled any other way is answered with a redirect to the canonical
7//! spelling, so two spellings of one path can never reach different routes,
8//! and a route literal compares with a segment byte for byte.
9
10use std::borrow::Cow;
11use std::fmt;
12
13/// What a request path was refused for (answered with 400).
14#[non_exhaustive]
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum UrlError {
17    /// The path does not start with `/` (and is not the `*` of `OPTIONS *`).
18    NotAbsolute,
19    /// A `%` not followed by two hex digits.
20    BadEscape,
21    /// An encoded slash (`%2F`): it would let one segment pose as two.
22    EncodedSlash,
23    /// An encoded backslash (`%5C`): a path separator on some systems.
24    Backslash,
25    /// A decoded control byte (`%00` to `%1F`, `%7F`).
26    Control,
27    /// The decoded bytes are not UTF-8.
28    NotUtf8,
29    /// More segments than the limit.
30    TooManySegments,
31}
32
33impl UrlError {
34    /// A stable code for logs and events.
35    #[must_use]
36    pub const fn code(self) -> &'static str {
37        match self {
38            Self::NotAbsolute => "url.not_absolute",
39            Self::BadEscape => "url.bad_escape",
40            Self::EncodedSlash => "url.encoded_slash",
41            Self::Backslash => "url.backslash",
42            Self::Control => "url.control",
43            Self::NotUtf8 => "url.not_utf8",
44            Self::TooManySegments => "url.too_many_segments",
45        }
46    }
47}
48
49impl fmt::Display for UrlError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        let what = match self {
52            Self::NotAbsolute => "the path does not start with a slash",
53            Self::BadEscape => "a percent sign is not followed by two hex digits",
54            Self::EncodedSlash => "the path holds an encoded slash",
55            Self::Backslash => "the path holds an encoded backslash",
56            Self::Control => "the path decodes to a control character",
57            Self::NotUtf8 => "the path does not decode to UTF-8",
58            Self::TooManySegments => "the path has too many segments",
59        };
60        f.write_str(what)
61    }
62}
63
64impl std::error::Error for UrlError {}
65
66/// What a site does with a trailing slash. One policy per site, so a page has
67/// one address.
68#[non_exhaustive]
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub enum TrailingSlash {
71    /// `/a/` is redirected to `/a` (the default). The site root keeps its
72    /// slash.
73    #[default]
74    Strip,
75    /// `/a` is redirected to `/a/`, except when the last segment holds a dot
76    /// (`/style.css` stays as it is).
77    Add,
78    /// Both spellings reach the same route and neither is redirected.
79    Ignore,
80}
81
82/// The most segments a path may have.
83pub(crate) const MAX_SEGMENTS: usize = 256;
84
85#[inline]
86const fn hex(b: u8) -> Option<u8> {
87    match b {
88        b'0'..=b'9' => Some(b - b'0'),
89        b'a'..=b'f' => Some(b - b'a' + 10),
90        b'A'..=b'F' => Some(b - b'A' + 10),
91        _ => None,
92    }
93}
94
95/// Bytes written as they are in a canonical path segment: RFC 3986 `pchar`
96/// without `%` (unreserved, sub-delims, `:` and `@`). Every other byte is
97/// percent-encoded with upper-case hex.
98#[inline]
99pub(crate) const fn is_plain(b: u8) -> bool {
100    matches!(b,
101        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
102        | b'-' | b'.' | b'_' | b'~'
103        | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
104        | b':' | b'@')
105}
106
107const HEX: &[u8; 16] = b"0123456789ABCDEF";
108
109/// Appends `bytes` to `out` in canonical segment encoding.
110pub(crate) fn encode_segment(bytes: &[u8], out: &mut String) {
111    for &b in bytes {
112        if is_plain(b) {
113            out.push(b as char);
114        } else {
115            out.push('%');
116            out.push(HEX[(b >> 4) as usize] as char);
117            out.push(HEX[(b & 15) as usize] as char);
118        }
119    }
120}
121
122/// Decodes a canonical segment (or several, joined by `/`). Canonical input
123/// always decodes to UTF-8; anything that does not is returned unchanged.
124pub(crate) fn decode(raw: &str) -> Cow<'_, str> {
125    if !raw.contains('%') {
126        return Cow::Borrowed(raw);
127    }
128    let b = raw.as_bytes();
129    let mut out = Vec::with_capacity(b.len());
130    let mut i = 0;
131    while i < b.len() {
132        if b[i] == b'%' && i + 2 < b.len() {
133            if let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2])) {
134                out.push((h << 4) | l);
135                i += 3;
136                continue;
137            }
138        }
139        out.push(b[i]);
140        i += 1;
141    }
142    match String::from_utf8(out) {
143        Ok(s) => Cow::Owned(s),
144        Err(_) => Cow::Borrowed(raw),
145    }
146}
147
148/// Whether `raw` is already canonical as far as decoding and dot segments
149/// go: no `%`, no empty segment, no `.` or `..` segment, and (with
150/// `lowercase`) no upper-case letter. The common case; no allocation.
151fn plain_path(raw: &[u8], lowercase: bool) -> bool {
152    let mut seg_start = 1;
153    let mut segments = 0usize;
154    for i in 1..=raw.len() {
155        let end = i == raw.len();
156        if end || raw[i] == b'/' {
157            let seg = &raw[seg_start..i];
158            // An empty segment is fine only as the trailing slash.
159            if (seg.is_empty() && !end) || seg == b"." || seg == b".." {
160                return false;
161            }
162            segments += 1;
163            seg_start = i + 1;
164            continue;
165        }
166        let b = raw[i];
167        if !is_plain(b) || (lowercase && b.is_ascii_uppercase()) {
168            return false;
169        }
170    }
171    segments <= MAX_SEGMENTS
172}
173
174/// Normalizes a request path: percent-escapes decoded once and re-encoded in
175/// the canonical way, dot segments removed (never above the root), empty
176/// segments dropped, letters lowered when `lowercase`. A trailing slash is
177/// kept; the site's [`TrailingSlash`] policy is applied later, after the
178/// base path.
179///
180/// Returns the input itself when it is canonical already.
181///
182/// # Errors
183///
184/// [`UrlError`] for a path that is refused rather than repaired.
185pub(crate) fn normalize(raw: &str, lowercase: bool) -> Result<Cow<'_, str>, UrlError> {
186    let b = raw.as_bytes();
187    if b.first() != Some(&b'/') {
188        return Err(UrlError::NotAbsolute);
189    }
190    if plain_path(b, lowercase) {
191        return Ok(Cow::Borrowed(raw));
192    }
193    // Slow path: decode every segment into one buffer, keeping where each
194    // kept segment starts and ends.
195    let mut buf: Vec<u8> = Vec::with_capacity(b.len());
196    let mut segs: Vec<(usize, usize)> = Vec::new();
197    let mut trailing = false;
198    let last_index = b[1..].iter().filter(|&&c| c == b'/').count();
199    if last_index >= MAX_SEGMENTS * 2 {
200        return Err(UrlError::TooManySegments);
201    }
202    for (n, seg) in b[1..].split(|&c| c == b'/').enumerate() {
203        let start = buf.len();
204        let mut i = 0;
205        while i < seg.len() {
206            let c = seg[i];
207            let byte = if c == b'%' {
208                let (Some(h), Some(l)) = (
209                    seg.get(i + 1).copied().and_then(hex),
210                    seg.get(i + 2).copied().and_then(hex),
211                ) else {
212                    return Err(UrlError::BadEscape);
213                };
214                i += 3;
215                let d = (h << 4) | l;
216                match d {
217                    b'/' => return Err(UrlError::EncodedSlash),
218                    b'\\' => return Err(UrlError::Backslash),
219                    0..=0x1f | 0x7f => return Err(UrlError::Control),
220                    _ => d,
221                }
222            } else {
223                i += 1;
224                if c == b'\\' {
225                    return Err(UrlError::Backslash);
226                }
227                if c < 0x20 || c == 0x7f {
228                    return Err(UrlError::Control);
229                }
230                c
231            };
232            buf.push(if lowercase {
233                byte.to_ascii_lowercase()
234            } else {
235                byte
236            });
237        }
238        let decoded = &buf[start..];
239        let last = n == last_index;
240        match decoded {
241            b"" => {
242                buf.truncate(start);
243                trailing = last;
244            }
245            b"." => {
246                buf.truncate(start);
247                trailing = last;
248            }
249            b".." => {
250                buf.truncate(start);
251                if let Some((s, _)) = segs.pop() {
252                    buf.truncate(s);
253                }
254                trailing = last;
255            }
256            _ => {
257                if core::str::from_utf8(decoded).is_err() {
258                    return Err(UrlError::NotUtf8);
259                }
260                segs.push((start, buf.len()));
261                trailing = false;
262            }
263        }
264    }
265    if segs.len() > MAX_SEGMENTS {
266        return Err(UrlError::TooManySegments);
267    }
268    let mut out = String::with_capacity(b.len());
269    for &(s, e) in &segs {
270        out.push('/');
271        encode_segment(&buf[s..e], &mut out);
272    }
273    if out.is_empty() || trailing {
274        out.push('/');
275    }
276    if out == raw {
277        Ok(Cow::Borrowed(raw))
278    } else {
279        Ok(Cow::Owned(out))
280    }
281}
282
283/// Canonical encoding of a route literal segment (written decoded by the
284/// programmer).
285pub(crate) fn canonical_literal(segment: &str) -> String {
286    let mut out = String::with_capacity(segment.len());
287    encode_segment(segment.as_bytes(), &mut out);
288    out
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn n(p: &str) -> Result<String, UrlError> {
296        normalize(p, false).map(Cow::into_owned)
297    }
298
299    #[test]
300    fn test_plain_paths_are_borrowed() {
301        for p in [
302            "/",
303            "/a",
304            "/a/b",
305            "/a/b/",
306            "/a-b_c.d~e",
307            "/a:b@c!$&'()*+,;=",
308        ] {
309            assert!(matches!(normalize(p, false), Ok(Cow::Borrowed(_))), "{p}");
310        }
311    }
312
313    #[test]
314    fn test_dot_segments_removed() {
315        assert_eq!(n("/a/./b/../c").unwrap(), "/a/c");
316        assert_eq!(n("/a/b/..").unwrap(), "/a/");
317        assert_eq!(n("/a/b/.").unwrap(), "/a/b/");
318        assert_eq!(n("/../../etc/passwd").unwrap(), "/etc/passwd");
319        assert_eq!(n("/..").unwrap(), "/");
320        assert_eq!(n("/%2e%2E/x").unwrap(), "/x");
321        assert_eq!(n("/a/%2e/b").unwrap(), "/a/b");
322    }
323
324    #[test]
325    fn test_empty_segments_collapse() {
326        assert_eq!(n("//a///b").unwrap(), "/a/b");
327        assert_eq!(n("/a//").unwrap(), "/a/");
328        assert_eq!(n("//").unwrap(), "/");
329    }
330
331    #[test]
332    fn test_escapes_canonical() {
333        assert_eq!(n("/%41%62c").unwrap(), "/Abc");
334        assert_eq!(n("/caf%c3%a9").unwrap(), "/caf%C3%A9");
335        assert_eq!(n("/caf%C3%A9").unwrap(), "/caf%C3%A9");
336        assert_eq!(n("/a%20b").unwrap(), "/a%20b");
337        assert_eq!(n("/a%3Ab").unwrap(), "/a:b");
338        assert_eq!(n("/a%25b").unwrap(), "/a%25b");
339        assert!(matches!(normalize("/a%20b", false), Ok(Cow::Borrowed(_))));
340    }
341
342    #[test]
343    fn test_refusals() {
344        assert_eq!(n("a"), Err(UrlError::NotAbsolute));
345        assert_eq!(n(""), Err(UrlError::NotAbsolute));
346        assert_eq!(n("/a%2Fb"), Err(UrlError::EncodedSlash));
347        assert_eq!(n("/a%2fb"), Err(UrlError::EncodedSlash));
348        assert_eq!(n("/a%5Cb"), Err(UrlError::Backslash));
349        assert_eq!(n("/a%00"), Err(UrlError::Control));
350        assert_eq!(n("/a%0d%0a"), Err(UrlError::Control));
351        assert_eq!(n("/a%7f"), Err(UrlError::Control));
352        assert_eq!(n("/a%zz"), Err(UrlError::BadEscape));
353        assert_eq!(n("/a%4"), Err(UrlError::BadEscape));
354        assert_eq!(n("/a%ff"), Err(UrlError::NotUtf8));
355        assert_eq!(n("/%c3"), Err(UrlError::NotUtf8));
356    }
357
358    #[test]
359    fn test_lowercase() {
360        assert_eq!(normalize("/About/Us", true).unwrap(), "/about/us");
361        assert_eq!(normalize("/%41", true).unwrap(), "/a");
362        assert!(matches!(normalize("/about", true), Ok(Cow::Borrowed(_))));
363    }
364
365    #[test]
366    fn test_normalize_is_idempotent() {
367        for p in [
368            "/a/./b/../c",
369            "//x//y/",
370            "/%41%2e%2e/b",
371            "/caf%c3%a9/",
372            "/a%20b/..",
373        ] {
374            let once = n(p).unwrap();
375            assert_eq!(n(&once).unwrap(), once, "{p}");
376        }
377    }
378
379    #[test]
380    fn test_decode() {
381        assert_eq!(decode("caf%C3%A9"), "café");
382        assert_eq!(decode("a%20b"), "a b");
383        assert_eq!(decode("plain"), "plain");
384    }
385
386    #[test]
387    fn test_too_many_segments() {
388        let long = "/a".repeat(MAX_SEGMENTS + 1);
389        assert_eq!(n(&long), Err(UrlError::TooManySegments));
390        let ok = "/a".repeat(MAX_SEGMENTS);
391        assert!(n(&ok).is_ok());
392    }
393}