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///
15/// ```
16/// use web_net::{App, UrlError, get};
17///
18/// let app = App::builder().route(get("/", |cx| cx.text(200, "ok"))).build()?;
19/// let r = serve_net::testing::respond(&app, b"GET /a%2Fb HTTP/1.1\r\nHost: localhost\r\n\r\n");
20/// assert_eq!(r.status, 400);
21/// assert_eq!(UrlError::EncodedSlash.code(), "url.encoded_slash");
22/// # Ok::<(), web_net::BuildError>(())
23/// ```
24#[non_exhaustive]
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum UrlError {
27    /// The path does not start with `/` (and is not the `*` of `OPTIONS *`).
28    NotAbsolute,
29    /// A `%` not followed by two hex digits.
30    BadEscape,
31    /// An encoded slash (`%2F`): it would let one segment pose as two.
32    EncodedSlash,
33    /// An encoded backslash (`%5C`): a path separator on some systems.
34    Backslash,
35    /// A decoded control byte (`%00` to `%1F`, `%7F`).
36    Control,
37    /// The decoded bytes are not UTF-8.
38    NotUtf8,
39    /// More segments than the limit.
40    TooManySegments,
41}
42
43impl UrlError {
44    /// A stable code for logs and events.
45    ///
46    /// ```
47    /// use web_net::UrlError;
48    ///
49    /// assert_eq!(UrlError::BadEscape.code(), "url.bad_escape");
50    /// assert_eq!(UrlError::NotUtf8.code(), "url.not_utf8");
51    /// ```
52    #[must_use]
53    pub const fn code(self) -> &'static str {
54        match self {
55            Self::NotAbsolute => "url.not_absolute",
56            Self::BadEscape => "url.bad_escape",
57            Self::EncodedSlash => "url.encoded_slash",
58            Self::Backslash => "url.backslash",
59            Self::Control => "url.control",
60            Self::NotUtf8 => "url.not_utf8",
61            Self::TooManySegments => "url.too_many_segments",
62        }
63    }
64}
65
66impl fmt::Display for UrlError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        let what = match self {
69            Self::NotAbsolute => "the path does not start with a slash",
70            Self::BadEscape => "a percent sign is not followed by two hex digits",
71            Self::EncodedSlash => "the path holds an encoded slash",
72            Self::Backslash => "the path holds an encoded backslash",
73            Self::Control => "the path decodes to a control character",
74            Self::NotUtf8 => "the path does not decode to UTF-8",
75            Self::TooManySegments => "the path has too many segments",
76        };
77        f.write_str(what)
78    }
79}
80
81impl std::error::Error for UrlError {}
82
83/// What a site does with a trailing slash. One policy per site, so a page has
84/// one address.
85///
86/// ```
87/// use web_net::{App, TrailingSlash, get};
88///
89/// let strict = App::builder().route(get("/about", |cx| cx.text(200, "about"))).build()?;
90/// let loose = App::builder()
91///     .trailing_slash(TrailingSlash::Ignore)
92///     .route(get("/about", |cx| cx.text(200, "about")))
93///     .build()?;
94/// let req = b"GET /about/ HTTP/1.1\r\nHost: localhost\r\n\r\n";
95/// assert_eq!(serve_net::testing::respond(&strict, req).header("location"), Some(&b"/about"[..]));
96/// assert_eq!(serve_net::testing::respond(&loose, req).text(), "about");
97/// # Ok::<(), web_net::BuildError>(())
98/// ```
99#[non_exhaustive]
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub enum TrailingSlash {
102    /// `/a/` is redirected to `/a` (the default). The site root keeps its
103    /// slash.
104    #[default]
105    Strip,
106    /// `/a` is redirected to `/a/`, except when the last segment holds a dot
107    /// (`/style.css` stays as it is).
108    Add,
109    /// Both spellings reach the same route and neither is redirected.
110    Ignore,
111}
112
113/// The most segments a path may have.
114pub(crate) const MAX_SEGMENTS: usize = 256;
115
116#[inline]
117const fn hex(b: u8) -> Option<u8> {
118    match b {
119        b'0'..=b'9' => Some(b - b'0'),
120        b'a'..=b'f' => Some(b - b'a' + 10),
121        b'A'..=b'F' => Some(b - b'A' + 10),
122        _ => None,
123    }
124}
125
126/// Bytes written as they are in a canonical path segment: RFC 3986 `pchar`
127/// without `%` (unreserved, sub-delims, `:` and `@`). Every other byte is
128/// percent-encoded with upper-case hex.
129#[inline]
130pub(crate) const fn is_plain(b: u8) -> bool {
131    matches!(b,
132        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
133        | b'-' | b'.' | b'_' | b'~'
134        | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
135        | b':' | b'@')
136}
137
138const HEX: &[u8; 16] = b"0123456789ABCDEF";
139
140/// Appends `bytes` to `out` in canonical segment encoding.
141pub(crate) fn encode_segment(bytes: &[u8], out: &mut String) {
142    for &b in bytes {
143        if is_plain(b) {
144            out.push(b as char);
145        } else {
146            out.push('%');
147            out.push(HEX[(b >> 4) as usize] as char);
148            out.push(HEX[(b & 15) as usize] as char);
149        }
150    }
151}
152
153/// Decodes a canonical segment (or several, joined by `/`). Canonical input
154/// always decodes to UTF-8; anything that does not is returned unchanged.
155pub(crate) fn decode(raw: &str) -> Cow<'_, str> {
156    if !raw.contains('%') {
157        return Cow::Borrowed(raw);
158    }
159    let b = raw.as_bytes();
160    let mut out = Vec::with_capacity(b.len());
161    let mut i = 0;
162    while i < b.len() {
163        if b[i] == b'%' && i + 2 < b.len() {
164            if let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2])) {
165                out.push((h << 4) | l);
166                i += 3;
167                continue;
168            }
169        }
170        out.push(b[i]);
171        i += 1;
172    }
173    match String::from_utf8(out) {
174        Ok(s) => Cow::Owned(s),
175        Err(_) => Cow::Borrowed(raw),
176    }
177}
178
179/// Whether `raw` is already canonical as far as decoding and dot segments
180/// go: no `%`, no empty segment, no `.` or `..` segment, and (with
181/// `lowercase`) no upper-case letter. The common case; no allocation.
182fn plain_path(raw: &[u8], lowercase: bool) -> bool {
183    let mut seg_start = 1;
184    let mut segments = 0usize;
185    for i in 1..=raw.len() {
186        let end = i == raw.len();
187        if end || raw[i] == b'/' {
188            let seg = &raw[seg_start..i];
189            // An empty segment is fine only as the trailing slash.
190            if (seg.is_empty() && !end) || seg == b"." || seg == b".." {
191                return false;
192            }
193            segments += 1;
194            seg_start = i + 1;
195            continue;
196        }
197        let b = raw[i];
198        if !is_plain(b) || (lowercase && b.is_ascii_uppercase()) {
199            return false;
200        }
201    }
202    segments <= MAX_SEGMENTS
203}
204
205/// Normalizes a request path: percent-escapes decoded once and re-encoded in
206/// the canonical way, dot segments removed (never above the root), empty
207/// segments dropped, letters lowered when `lowercase`. A trailing slash is
208/// kept; the site's [`TrailingSlash`] policy is applied later, after the
209/// base path.
210///
211/// Returns the input itself when it is canonical already.
212///
213/// # Errors
214///
215/// [`UrlError`] for a path that is refused rather than repaired.
216pub(crate) fn normalize(raw: &str, lowercase: bool) -> Result<Cow<'_, str>, UrlError> {
217    let b = raw.as_bytes();
218    if b.first() != Some(&b'/') {
219        return Err(UrlError::NotAbsolute);
220    }
221    if plain_path(b, lowercase) {
222        return Ok(Cow::Borrowed(raw));
223    }
224    // Slow path: decode every segment into one buffer, keeping where each
225    // kept segment starts and ends.
226    let mut buf: Vec<u8> = Vec::with_capacity(b.len());
227    let mut segs: Vec<(usize, usize)> = Vec::new();
228    let mut trailing = false;
229    let last_index = b[1..].iter().filter(|&&c| c == b'/').count();
230    if last_index >= MAX_SEGMENTS * 2 {
231        return Err(UrlError::TooManySegments);
232    }
233    for (n, seg) in b[1..].split(|&c| c == b'/').enumerate() {
234        let start = buf.len();
235        let mut i = 0;
236        while i < seg.len() {
237            let c = seg[i];
238            let byte = if c == b'%' {
239                let (Some(h), Some(l)) = (
240                    seg.get(i + 1).copied().and_then(hex),
241                    seg.get(i + 2).copied().and_then(hex),
242                ) else {
243                    return Err(UrlError::BadEscape);
244                };
245                i += 3;
246                let d = (h << 4) | l;
247                match d {
248                    b'/' => return Err(UrlError::EncodedSlash),
249                    b'\\' => return Err(UrlError::Backslash),
250                    0..=0x1f | 0x7f => return Err(UrlError::Control),
251                    _ => d,
252                }
253            } else {
254                i += 1;
255                if c == b'\\' {
256                    return Err(UrlError::Backslash);
257                }
258                if c < 0x20 || c == 0x7f {
259                    return Err(UrlError::Control);
260                }
261                c
262            };
263            buf.push(if lowercase {
264                byte.to_ascii_lowercase()
265            } else {
266                byte
267            });
268        }
269        let decoded = &buf[start..];
270        let last = n == last_index;
271        match decoded {
272            b"" => {
273                buf.truncate(start);
274                trailing = last;
275            }
276            b"." => {
277                buf.truncate(start);
278                trailing = last;
279            }
280            b".." => {
281                buf.truncate(start);
282                if let Some((s, _)) = segs.pop() {
283                    buf.truncate(s);
284                }
285                trailing = last;
286            }
287            _ => {
288                if core::str::from_utf8(decoded).is_err() {
289                    return Err(UrlError::NotUtf8);
290                }
291                // C1 controls (U+0080 to U+009F) are 0xC2 0x80..=0x9F in
292                // UTF-8; in valid UTF-8 a 0xC2 is always a lead byte.
293                if decoded
294                    .windows(2)
295                    .any(|w| w[0] == 0xC2 && (0x80..=0x9F).contains(&w[1]))
296                {
297                    return Err(UrlError::Control);
298                }
299                segs.push((start, buf.len()));
300                trailing = false;
301            }
302        }
303    }
304    if segs.len() > MAX_SEGMENTS {
305        return Err(UrlError::TooManySegments);
306    }
307    let mut out = String::with_capacity(b.len());
308    for &(s, e) in &segs {
309        out.push('/');
310        encode_segment(&buf[s..e], &mut out);
311    }
312    if out.is_empty() || trailing {
313        out.push('/');
314    }
315    if out == raw {
316        Ok(Cow::Borrowed(raw))
317    } else {
318        Ok(Cow::Owned(out))
319    }
320}
321
322/// Canonical encoding of a route literal segment (written decoded by the
323/// programmer).
324pub(crate) fn canonical_literal(segment: &str) -> String {
325    let mut out = String::with_capacity(segment.len());
326    encode_segment(segment.as_bytes(), &mut out);
327    out
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn n(p: &str) -> Result<String, UrlError> {
335        normalize(p, false).map(Cow::into_owned)
336    }
337
338    #[test]
339    fn test_plain_paths_are_borrowed() {
340        for p in [
341            "/",
342            "/a",
343            "/a/b",
344            "/a/b/",
345            "/a-b_c.d~e",
346            "/a:b@c!$&'()*+,;=",
347        ] {
348            assert!(matches!(normalize(p, false), Ok(Cow::Borrowed(_))), "{p}");
349        }
350    }
351
352    #[test]
353    fn test_dot_segments_removed() {
354        assert_eq!(n("/a/./b/../c").unwrap(), "/a/c");
355        assert_eq!(n("/a/b/..").unwrap(), "/a/");
356        assert_eq!(n("/a/b/.").unwrap(), "/a/b/");
357        assert_eq!(n("/../../etc/passwd").unwrap(), "/etc/passwd");
358        assert_eq!(n("/..").unwrap(), "/");
359        assert_eq!(n("/%2e%2E/x").unwrap(), "/x");
360        assert_eq!(n("/a/%2e/b").unwrap(), "/a/b");
361    }
362
363    #[test]
364    fn test_empty_segments_collapse() {
365        assert_eq!(n("//a///b").unwrap(), "/a/b");
366        assert_eq!(n("/a//").unwrap(), "/a/");
367        assert_eq!(n("//").unwrap(), "/");
368    }
369
370    #[test]
371    fn test_escapes_canonical() {
372        assert_eq!(n("/%41%62c").unwrap(), "/Abc");
373        assert_eq!(n("/caf%c3%a9").unwrap(), "/caf%C3%A9");
374        assert_eq!(n("/caf%C3%A9").unwrap(), "/caf%C3%A9");
375        assert_eq!(n("/a%20b").unwrap(), "/a%20b");
376        assert_eq!(n("/a%3Ab").unwrap(), "/a:b");
377        assert_eq!(n("/a%25b").unwrap(), "/a%25b");
378        assert!(matches!(normalize("/a%20b", false), Ok(Cow::Borrowed(_))));
379    }
380
381    #[test]
382    fn test_refusals() {
383        assert_eq!(n("a"), Err(UrlError::NotAbsolute));
384        assert_eq!(n(""), Err(UrlError::NotAbsolute));
385        assert_eq!(n("/a%2Fb"), Err(UrlError::EncodedSlash));
386        assert_eq!(n("/a%2fb"), Err(UrlError::EncodedSlash));
387        assert_eq!(n("/a%5Cb"), Err(UrlError::Backslash));
388        assert_eq!(n("/a%00"), Err(UrlError::Control));
389        assert_eq!(n("/a%0d%0a"), Err(UrlError::Control));
390        assert_eq!(n("/a%7f"), Err(UrlError::Control));
391        // C1 controls (U+0080 to U+009F, NEL among them) are controls too.
392        assert_eq!(n("/%c2%80"), Err(UrlError::Control));
393        assert_eq!(n("/a%C2%85b"), Err(UrlError::Control));
394        assert_eq!(n("/a/%c2%9f"), Err(UrlError::Control));
395        // Their neighbours are text.
396        assert_eq!(n("/%c2%a0").as_deref(), Ok("/%C2%A0"));
397        assert_eq!(n("/caf%c3%a9").as_deref(), Ok("/caf%C3%A9"));
398        assert_eq!(n("/a%zz"), Err(UrlError::BadEscape));
399        assert_eq!(n("/a%4"), Err(UrlError::BadEscape));
400        assert_eq!(n("/a%ff"), Err(UrlError::NotUtf8));
401        assert_eq!(n("/%c3"), Err(UrlError::NotUtf8));
402    }
403
404    #[test]
405    fn test_lowercase() {
406        assert_eq!(normalize("/About/Us", true).unwrap(), "/about/us");
407        assert_eq!(normalize("/%41", true).unwrap(), "/a");
408        assert!(matches!(normalize("/about", true), Ok(Cow::Borrowed(_))));
409    }
410
411    #[test]
412    fn test_normalize_is_idempotent() {
413        for p in [
414            "/a/./b/../c",
415            "//x//y/",
416            "/%41%2e%2e/b",
417            "/caf%c3%a9/",
418            "/a%20b/..",
419        ] {
420            let once = n(p).unwrap();
421            assert_eq!(n(&once).unwrap(), once, "{p}");
422        }
423    }
424
425    #[test]
426    fn test_decode() {
427        assert_eq!(decode("caf%C3%A9"), "café");
428        assert_eq!(decode("a%20b"), "a b");
429        assert_eq!(decode("plain"), "plain");
430    }
431
432    mod props {
433        use proptest::prelude::*;
434
435        use super::super::*;
436
437        /// Bytes a request target can carry in a path (what http-net lets
438        /// through), with `%` escapes in both cases.
439        fn raw_path() -> impl Strategy<Value = String> {
440            let piece = prop_oneof![
441                Just("/".to_owned()),
442                Just("//".to_owned()),
443                Just("/./".to_owned()),
444                Just("/../".to_owned()),
445                Just(".".to_owned()),
446                Just("..".to_owned()),
447                "[a-zA-Z0-9._~!$&'()*+,;=:@-]{1,6}",
448                (0u8..=255).prop_map(|b| format!("%{b:02X}")),
449                (0u8..=255).prop_map(|b| format!("%{b:02x}")),
450            ];
451            proptest::collection::vec(piece, 0..12).prop_map(|v| format!("/{}", v.concat()))
452        }
453
454        /// A segment's bytes as decoded text: no `/`, `\\`, or control
455        /// (C0, DEL, or C1).
456        fn segment() -> impl Strategy<Value = String> {
457            "[^/\\\\\\x00-\\x1f\\x7f-\\x{9f}]{1,8}"
458                .prop_filter("not a dot segment", |s| s != "." && s != "..")
459        }
460
461        /// One way to spell a segment: each byte plain or escaped, hex in
462        /// either case.
463        fn spell(seg: &str, choices: &[u8]) -> String {
464            let mut out = String::new();
465            for (i, &b) in seg.as_bytes().iter().enumerate() {
466                let c = choices.get(i).copied().unwrap_or(0);
467                if is_plain(b) && c % 3 == 0 {
468                    out.push(b as char);
469                } else if c % 3 == 1 {
470                    out.push_str(&format!("%{b:02x}"));
471                } else {
472                    out.push_str(&format!("%{b:02X}"));
473                }
474            }
475            out
476        }
477
478        proptest! {
479            #[test]
480            fn prop_normalize_is_idempotent_and_clean(p in raw_path()) {
481                if let Ok(c) = normalize(&p, false) {
482                    let again = normalize(&c, false).unwrap();
483                    prop_assert!(matches!(again, Cow::Borrowed(_)), "{p} -> {c} -> {again}");
484                    prop_assert!(c.starts_with('/'));
485                    for bad in ["//", "/./", "/../"] {
486                        prop_assert!(!c.contains(bad), "{p} -> {c}");
487                    }
488                    prop_assert!(!c.ends_with("/.") && !c.ends_with("/.."), "{p} -> {c}");
489                    let decoded = decode(&c);
490                    prop_assert!(!decoded.contains('\\') && !decoded.chars().any(char::is_control));
491                }
492            }
493
494            #[test]
495            fn prop_every_spelling_reaches_one_path(
496                segs in proptest::collection::vec(segment(), 1..5),
497                choices in proptest::collection::vec(any::<u8>(), 64),
498                noise in proptest::collection::vec(0u8..4, 5),
499            ) {
500                let mut canonical = String::new();
501                let mut spelled = String::new();
502                for (i, s) in segs.iter().enumerate() {
503                    canonical.push('/');
504                    encode_segment(s.as_bytes(), &mut canonical);
505                    match noise[i % noise.len()] {
506                        1 => spelled.push_str("/."),
507                        2 => spelled.push_str("/x/.."),
508                        3 => spelled.push('/'),
509                        _ => {}
510                    }
511                    spelled.push('/');
512                    spelled.push_str(&spell(s, &choices[i * 8 % 56..]));
513                }
514                let n = normalize(&spelled, false).unwrap();
515                prop_assert_eq!(n.as_ref(), canonical.as_str(), "{}", spelled);
516            }
517
518            #[test]
519            fn prop_decode_inverts_encode(s in "\\PC{0,16}") {
520                let mut e = String::new();
521                encode_segment(s.as_bytes(), &mut e);
522                let d = decode(&e);
523                prop_assert_eq!(d.as_ref(), s.as_str());
524            }
525        }
526    }
527
528    #[test]
529    fn test_too_many_segments() {
530        let long = "/a".repeat(MAX_SEGMENTS + 1);
531        assert_eq!(n(&long), Err(UrlError::TooManySegments));
532        let ok = "/a".repeat(MAX_SEGMENTS);
533        assert!(n(&ok).is_ok());
534    }
535}