Skip to main content

rws_base/url/
mod.rs

1//! URL helpers: percent encoding (`URL::percent_encode` / `percent_decode`),
2//! query strings (`URL::build_query` / `parse_query`) and whole URLs
3//! (`URL::build` / `parse`, a panic-free RFC 3986 parser).
4//! `URL::parse_request_target` gives a request's path and query without
5//! inventing a scheme and host. Percent encoding is what lets a query value
6//! carry a space (`%20`), `=` (`%3D`) or `&` (`%26`). Examples:
7//! `crates/rws-base/src/url/example/`.
8
9#[cfg(test)]
10mod tests;
11#[cfg(test)]
12mod example;
13pub mod path;
14
15use std::collections::HashMap;
16use url_build_parse::{build_url, UrlAuthority, UrlComponents, UrlUserInfo};
17use url_search_params::{build_url_search_params, encode_uri_component, parse_url_search_params};
18use url_search_params::decode_uri_component;
19
20/// URL parsing, building, and percent-encoding utilities.
21pub struct URL;
22
23impl URL {
24    pub fn percent_encode(component: &str) -> String {
25        encode_uri_component(component)
26    }
27
28    pub fn percent_decode(component: &str) -> String {
29        decode_uri_component(component)
30    }
31
32    /// A query or form value: `+` is a space, `%HH` a byte, the bytes read
33    /// as UTF-8 (invalid sequences become U+FFFD); a `%` without two hex
34    /// digits stays as it is (18.24.0).
35    pub fn form_decode(component: &str) -> String {
36        let b = component.as_bytes();
37        let mut out = Vec::with_capacity(b.len());
38        let mut i = 0;
39        while i < b.len() {
40            let hex = |c: u8| (c as char).to_digit(16);
41            match b[i] {
42                b'+' => out.push(b' '),
43                b'%' => match (b.get(i + 1).and_then(|c| hex(*c)), b.get(i + 2).and_then(|c| hex(*c))) {
44                    (Some(h), Some(l)) => {
45                        out.push((h * 16 + l) as u8);
46                        i += 2;
47                    }
48                    _ => out.push(b'%'),
49                },
50                c => out.push(c),
51            }
52            i += 1;
53        }
54        String::from_utf8_lossy(&out).into_owned()
55    }
56
57    pub fn build_query(params: HashMap<String, String>) -> String {
58        build_url_search_params(params)
59    }
60
61    pub fn parse_query(component: &str) -> HashMap<String, String> {
62        parse_url_search_params(component)
63    }
64
65    pub fn build(components: UrlComponents) -> Result<String, String> {
66        build_url(components)
67    }
68
69    pub fn parse(url: &str) -> Result<UrlComponents, String> {
70        parse_url(url)
71    }
72
73    /// Parse an origin-form request target (`/path?query`) — what
74    /// `Request::request_uri` holds. Anything not starting with `/` is an
75    /// error rather than being glued onto a placeholder authority, where a
76    /// target like `:x` would become the port of `http://localhost:x`.
77    pub fn parse_request_target(target: &str) -> Result<UrlComponents, String> {
78        if !target.starts_with('/') {
79            return Err(format!("not an origin-form request target: {:?}", target));
80        }
81        parse_url(&format!("http://localhost{}", target))
82    }
83
84    /// The path of an origin-form request target — everything before the
85    /// first `?` or `#` — borrowed, without parsing or allocating. Same value
86    /// as [`URL::parse_request_target`]`(target)?.path`; `None` for a target
87    /// that isn't origin-form. For per-request matching on hot paths.
88    #[doc(hidden)]
89    pub fn request_target_path(target: &str) -> Option<&str> {
90        if !target.starts_with('/') {
91            return None;
92        }
93        Some(&target[..target.find(['?', '#']).unwrap_or(target.len())])
94    }
95}
96
97/// RFC 3986 parse of `scheme:[//authority]path[?query][#fragment]` into
98/// url-build-parse's `UrlComponents`, without panicking on malformed input.
99/// (url-build-parse's own `parse_url` unwraps on input-derived values — e.g.
100/// a non-numeric port — so a hostile URL could panic the caller.)
101fn parse_url(url: &str) -> Result<UrlComponents, String> {
102    let (scheme, rest) = url.split_once(':').ok_or("unable to identify scheme")?;
103    if rest.is_empty() {
104        return Err("error: remaining url is empty".to_string());
105    }
106    let mut components = UrlComponents::new();
107    components.scheme = scheme.to_string();
108
109    // Authority: after "//", up to the first '/', '?' or '#'.
110    let mut remaining = rest;
111    if let Some(after) = rest.strip_prefix("//") {
112        let end = after.find(['/', '?', '#']).unwrap_or(after.len());
113        components.authority = Some(parse_authority(&after[..end])?);
114        remaining = &after[end..];
115    }
116
117    // Path: up to the first '?' or '#'.
118    let path_end = remaining.find(['?', '#']).unwrap_or(remaining.len());
119    components.path = remaining[..path_end].to_string();
120    remaining = &remaining[path_end..];
121
122    if let Some(after) = remaining.strip_prefix('?') {
123        let query_end = after.find('#').unwrap_or(after.len());
124        components.query = Some(parse_url_search_params(&after[..query_end]));
125        remaining = &after[query_end..];
126    }
127    if let Some(fragment) = remaining.strip_prefix('#') {
128        components.fragment = Some(fragment.to_string());
129    }
130    Ok(components)
131}
132
133/// `[userinfo@]host[:port]`, with `[...]` IPv6 hosts.
134fn parse_authority(authority: &str) -> Result<UrlAuthority, String> {
135    let (user_info, host_port) = match authority.split_once('@') {
136        Some((user_info, host_port)) => {
137            let user_info = match user_info.split_once(':') {
138                Some((username, password)) => UrlUserInfo {
139                    username: username.to_string(),
140                    password: Some(password.to_string()),
141                },
142                None => UrlUserInfo { username: user_info.to_string(), password: None },
143            };
144            (Some(user_info), host_port)
145        }
146        None => (None, authority),
147    };
148
149    let (host, port_str) = if host_port.starts_with('[') {
150        let close = host_port.find(']').ok_or("unterminated IPv6 host")?;
151        let after = &host_port[close + 1..];
152        let port = match after.strip_prefix(':') {
153            Some(port) => Some(port),
154            None if after.is_empty() => None,
155            None => return Err(format!("unexpected characters after IPv6 host: {:?}", after)),
156        };
157        (&host_port[..=close], port)
158    } else {
159        match host_port.split_once(':') {
160            Some((host, port)) => (host, Some(port)),
161            None => (host_port, None),
162        }
163    };
164
165    let port = match port_str {
166        Some(port) => Some(port.parse::<usize>().map_err(|_| format!("invalid port: {:?}", port))?),
167        None => None,
168    };
169    Ok(UrlAuthority { user_info, host: host.to_string(), port })
170}
171