Skip to main content

rustlavel_http/
url.rs

1//! Percent-encoding and query-string handling.
2
3/// Decode `%XX` escapes and `+` (which means a space in query strings).
4pub fn decode(input: &str) -> String {
5    if !input.contains('%') && !input.contains('+') {
6        return input.to_string();
7    }
8
9    let bytes = input.as_bytes();
10    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
11    let mut i = 0;
12    while i < bytes.len() {
13        match bytes[i] {
14            b'%' if i + 2 < bytes.len() => match hex_pair(bytes[i + 1], bytes[i + 2]) {
15                Some(byte) => {
16                    out.push(byte);
17                    i += 3;
18                }
19                // A stray `%` is kept verbatim rather than dropped.
20                None => {
21                    out.push(b'%');
22                    i += 1;
23                }
24            },
25            b'+' => {
26                out.push(b' ');
27                i += 1;
28            }
29            byte => {
30                out.push(byte);
31                i += 1;
32            }
33        }
34    }
35
36    String::from_utf8_lossy(&out).into_owned()
37}
38
39fn hex_pair(high: u8, low: u8) -> Option<u8> {
40    Some(hex_digit(high)? << 4 | hex_digit(low)?)
41}
42
43fn hex_digit(byte: u8) -> Option<u8> {
44    match byte {
45        b'0'..=b'9' => Some(byte - b'0'),
46        b'a'..=b'f' => Some(byte - b'a' + 10),
47        b'A'..=b'F' => Some(byte - b'A' + 10),
48        _ => None,
49    }
50}
51
52/// Percent-encode everything outside the unreserved set.
53pub fn encode(input: &str) -> String {
54    let mut out = String::with_capacity(input.len());
55    for byte in input.bytes() {
56        match byte {
57            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
58                out.push(byte as char)
59            }
60            _ => out.push_str(&format!("%{byte:02X}")),
61        }
62    }
63    out
64}
65
66/// Parse `a=1&b=2` into pairs, decoding both sides.
67///
68/// Pairs are kept in order and duplicates preserved, so `tags[]=a&tags[]=b`
69/// can be read as a list.
70pub fn parse_query(query: &str) -> Vec<(String, String)> {
71    query
72        .split('&')
73        .filter(|part| !part.is_empty())
74        .map(|part| match part.split_once('=') {
75            Some((key, value)) => (decode(key), decode(value)),
76            None => (decode(part), String::new()),
77        })
78        .collect()
79}
80
81/// Split a request target into its path and raw query string.
82pub fn split_target(target: &str) -> (&str, &str) {
83    match target.split_once('?') {
84        Some((path, query)) => (path, query),
85        None => (target, ""),
86    }
87}
88
89/// Collapse `.` and `..` segments and reject anything that escapes the root.
90///
91/// Used before serving files from disk so a request cannot walk out of the
92/// public directory.
93pub fn normalize_path(path: &str) -> Option<String> {
94    let mut segments: Vec<&str> = Vec::new();
95    for segment in path.split('/') {
96        match segment {
97            "" | "." => continue,
98            ".." => {
99                segments.pop()?;
100            }
101            // A decoded segment must never reintroduce a separator.
102            s if s.contains('\\') || s.contains('\0') => return None,
103            s => segments.push(s),
104        }
105    }
106    Some(format!("/{}", segments.join("/")))
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn decodes_escapes_and_plus() {
115        assert_eq!(decode("hello+world"), "hello world");
116        assert_eq!(decode("caf%C3%A9"), "café");
117        assert_eq!(decode("100%"), "100%");
118        assert_eq!(decode("plain"), "plain");
119    }
120
121    #[test]
122    fn encoding_round_trips() {
123        let original = "a b/c?d=é";
124        assert_eq!(decode(&encode(original)), original);
125    }
126
127    #[test]
128    fn parses_query_pairs_in_order() {
129        let pairs = parse_query("name=Rust+lavel&tags=a&tags=b&empty");
130
131        assert_eq!(pairs[0], ("name".to_string(), "Rust lavel".to_string()));
132        assert_eq!(pairs[2], ("tags".to_string(), "b".to_string()));
133        assert_eq!(pairs[3], ("empty".to_string(), String::new()));
134    }
135
136    #[test]
137    fn splits_a_request_target() {
138        assert_eq!(split_target("/users?page=2"), ("/users", "page=2"));
139        assert_eq!(split_target("/users"), ("/users", ""));
140    }
141
142    #[test]
143    fn normalization_blocks_directory_traversal() {
144        assert_eq!(normalize_path("/css//app.css").as_deref(), Some("/css/app.css"));
145        assert_eq!(normalize_path("/a/./b").as_deref(), Some("/a/b"));
146        assert_eq!(normalize_path("/a/../b").as_deref(), Some("/b"));
147        assert_eq!(normalize_path("/../etc/passwd"), None);
148    }
149}