Skip to main content

crcbl_shell/
url.rs

1//! The one kind of URL [`Shell::open_url`](crate::Shell::open_url) hands to
2//! the system: an absolute `http` or `https` URL, checked before any backend
3//! sees it.
4//!
5//! # Decision: a type, not a string the backends each check
6//!
7//! What a platform does with a string it is told to "open" is far wider than
8//! "show a web page". `ShellExecuteW` with the `open` verb runs an executable
9//! (`C:\x.exe`), opens any file with its associated program (`\\server\share\
10//! payload.lnk`), and starts any registered protocol handler (`ms-msdt:`,
11//! `search-ms:`, `file:`), each of which has been a remote-code-execution path
12//! in its own right. `xdg-open` and `NSWorkspace openURL` are as broad. A game
13//! that passes on a link it was sent — a server's news feed, a mod's homepage,
14//! a chat message — must never reach any of that, so the check cannot be left
15//! to each backend to remember. [`WebUrl`] is the only thing
16//! [`open_url`](crate::Shell::open_url) takes, and [`WebUrl::parse`] is the
17//! only way to make one, so a string that fails the check never reaches a
18//! system call on any platform.
19//!
20//! # What is accepted
21//!
22//! RFC 3986's `URI` with the scheme `http` or `https` (in any case, as
23//! section 3.1 says schemes are) and an authority: `//`, a host, and an
24//! optional port. Stricter than the grammar in four ways, each on the side of
25//! refusing:
26//!
27//! * **Only the characters RFC 3986 lets a URI contain** (section 2): the
28//!   unreserved set, the delimiters, and `%` followed by two hex digits.
29//!   Everything else must arrive percent-encoded, which turns away whitespace
30//!   at either end or inside, NUL and every other control character, the `"`
31//!   that would end a quoted argument on the browser's command line, the `\`
32//!   that Windows reads as a separator, and non-ASCII text.
33//! * **No user information.** `https://bank.example@evil.example` names the
34//!   host after the `@`, which is how a link is made to read as one site and
35//!   go to another; a game has no use for a password in a URL either.
36//! * **`[` and `]` only around an IPv6 address literal** in the host, as
37//!   section 3.2.2 says, and no `IPvFuture` literal.
38//! * **A port is one to five digits, at most 65535**, where the grammar allows
39//!   any run of digits or none.
40//!
41//! The string is kept exactly as given: nothing is decoded, normalised or
42//! lower-cased, so what the browser opens is what the caller passed.
43
44use core::fmt;
45
46/// An absolute `http` or `https` URL, the only kind
47/// [`Shell::open_url`](crate::Shell::open_url) opens.
48///
49/// Made by [`parse`](Self::parse); see the [module docs](self) for what it
50/// accepts and why it is this strict.
51///
52/// ```
53/// use crcbl_shell::{WebUrl, WebUrlError};
54///
55/// let url = WebUrl::parse("https://store.example/app/1234?ref=menu")?;
56/// assert_eq!(url.as_str(), "https://store.example/app/1234?ref=menu");
57///
58/// assert_eq!(WebUrl::parse(r"C:\game\patch.exe"), Err(WebUrlError::Character {
59///     found: '\\',
60///     at: 2,
61/// }));
62/// assert_eq!(WebUrl::parse("file:///C:/game/patch.exe"), Err(WebUrlError::Scheme));
63/// # Ok::<(), WebUrlError>(())
64/// ```
65#[derive(Clone, Debug, PartialEq, Eq, Hash)]
66pub struct WebUrl(String);
67
68/// Why a string is not a [`WebUrl`].
69#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
70#[non_exhaustive]
71pub enum WebUrlError {
72    /// A character a URL may only carry percent-encoded: whitespace, a
73    /// control character, `"`, `\`, `<`, `>`, `^`, `` ` ``, `{`, `|`, `}`,
74    /// anything outside ASCII, or a `[` or `]` outside the host.
75    #[error("{found:?} at byte {at} must be percent-encoded in a URL")]
76    Character {
77        /// The character.
78        found: char,
79        /// Its byte offset in the string.
80        at: usize,
81    },
82
83    /// A `%` not followed by two hex digits.
84    #[error("the % at byte {at} does not start a percent escape")]
85    Escape {
86        /// The `%`'s byte offset in the string.
87        at: usize,
88    },
89
90    /// The scheme is missing or is not `http` or `https`.
91    #[error("only http and https URLs can be opened")]
92    Scheme,
93
94    /// No `//` after the scheme, an empty host, or a malformed IPv6 literal.
95    #[error("the URL has no host, or a malformed one")]
96    Host,
97
98    /// A user name or password before the host.
99    #[error("a URL with a user name or password before the host is refused")]
100    UserInfo,
101
102    /// A port that is empty, not decimal, or above 65535.
103    #[error("the URL's port is not a number from 0 to 65535")]
104    Port,
105}
106
107impl WebUrl {
108    /// `text` as a [`WebUrl`], if it is an absolute `http` or `https` URL that
109    /// passes every check in the [module docs](self).
110    ///
111    /// # Errors
112    ///
113    /// The first check `text` fails, as a [`WebUrlError`].
114    pub fn parse(text: &str) -> Result<Self, WebUrlError> {
115        check_characters(text)?;
116        let (scheme, rest) = text.split_once(':').ok_or(WebUrlError::Scheme)?;
117        if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
118            return Err(WebUrlError::Scheme);
119        }
120        let rest = rest.strip_prefix("//").ok_or(WebUrlError::Host)?;
121        let (authority, tail) = rest.split_at(rest.find(['/', '?', '#']).unwrap_or(rest.len()));
122        check_authority(authority)?;
123        let tail_start = text.len() - tail.len();
124        if let Some((offset, found)) = tail.char_indices().find(|&(_, c)| c == '[' || c == ']') {
125            return Err(WebUrlError::Character {
126                found,
127                at: tail_start + offset,
128            });
129        }
130        Ok(Self(text.to_owned()))
131    }
132
133    /// The URL, exactly as it was parsed.
134    #[must_use]
135    pub fn as_str(&self) -> &str {
136        &self.0
137    }
138}
139
140impl fmt::Display for WebUrl {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.write_str(&self.0)
143    }
144}
145
146/// Every character is one RFC 3986 section 2 lets a URI contain, and every `%`
147/// begins an escape.
148fn check_characters(text: &str) -> Result<(), WebUrlError> {
149    let bytes = text.as_bytes();
150    for (at, found) in text.char_indices() {
151        match found {
152            '%' => {
153                let escape = bytes.get(at + 1..at + 3);
154                if !escape.is_some_and(|digits| digits.iter().all(u8::is_ascii_hexdigit)) {
155                    return Err(WebUrlError::Escape { at });
156                }
157            }
158            'A'..='Z' | 'a'..='z' | '0'..='9' => {}
159            // Unreserved, then the general and the sub-delimiters.
160            '-' | '.' | '_' | '~' => {}
161            ':' | '/' | '?' | '#' | '[' | ']' | '@' => {}
162            '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' => {}
163            _ => return Err(WebUrlError::Character { found, at }),
164        }
165    }
166    Ok(())
167}
168
169/// The authority is a non-empty host, a reg-name or a bracketed IPv6 literal,
170/// with an optional port and no user information. Its characters have already
171/// been checked, and `/`, `?` and `#` cannot be in it, since they end it.
172fn check_authority(authority: &str) -> Result<(), WebUrlError> {
173    if authority.contains('@') {
174        return Err(WebUrlError::UserInfo);
175    }
176    let port = if let Some(literal) = authority.strip_prefix('[') {
177        let (address, after) = literal.split_once(']').ok_or(WebUrlError::Host)?;
178        let is_ipv6 = address.contains(':')
179            && address
180                .bytes()
181                .all(|byte| byte.is_ascii_hexdigit() || byte == b':' || byte == b'.');
182        if !is_ipv6 {
183            return Err(WebUrlError::Host);
184        }
185        if after.is_empty() {
186            None
187        } else {
188            Some(after.strip_prefix(':').ok_or(WebUrlError::Host)?)
189        }
190    } else {
191        if authority.contains(['[', ']']) {
192            return Err(WebUrlError::Host);
193        }
194        let (host, port) = authority
195            .split_once(':')
196            .map_or((authority, None), |(host, port)| (host, Some(port)));
197        if host.is_empty() {
198            return Err(WebUrlError::Host);
199        }
200        port
201    };
202    match port {
203        None => Ok(()),
204        Some(port)
205            if (1..=5).contains(&port.len())
206                && port.bytes().all(|byte| byte.is_ascii_digit())
207                && port.parse::<u32>().is_ok_and(|number| number <= 65535) =>
208        {
209            Ok(())
210        }
211        Some(_) => Err(WebUrlError::Port),
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn refused(text: &str) -> WebUrlError {
220        WebUrl::parse(text).expect_err(text)
221    }
222
223    #[test]
224    fn web_urls_are_accepted_verbatim() {
225        for text in [
226            "http://example.com",
227            "https://example.com/",
228            "https://store.example/app/1234?ref=menu&x=1#reviews",
229            "HTTPS://EXAMPLE.COM/PATH",
230            "HtTp://example.com",
231            "https://example.com:443/",
232            "http://example.com:0",
233            "https://example.com:65535/x",
234            "https://127.0.0.1:8080/",
235            "https://[::1]/",
236            "https://[2001:db8::7]:8443/a?b#c",
237            "https://[::ffff:192.0.2.1]",
238            "https://example.com/a%20b/%C3%A9?q=%2F",
239            "https://example.com?query",
240            "https://example.com#fragment",
241            "https://example.com/!$&'()*+,;=:@-._~",
242            "https://xn--bcher-kva.example/",
243        ] {
244            let url = WebUrl::parse(text).unwrap_or_else(|error| panic!("{text}: {error}"));
245            assert_eq!(url.as_str(), text, "kept as given");
246            assert_eq!(url.to_string(), text);
247        }
248    }
249
250    #[test]
251    fn other_schemes_are_refused() {
252        for text in [
253            "file:///C:/Windows/System32/calc.exe",
254            "FILE://server/share/x.lnk",
255            "javascript:alert(1)",
256            "ms-msdt:/id",
257            "search-ms:query=x",
258            "shell:startup",
259            "about:blank",
260            "ftp://example.com/",
261            "httpx://example.com",
262            "https+x://example.com",
263            "data:text/html,x",
264            "mailto:someone@example.com",
265            "C:/game/patch.exe",
266            "://example.com",
267            "",
268            "example.com",
269            "/usr/bin/xterm",
270            "relative/path",
271        ] {
272            assert_eq!(refused(text), WebUrlError::Scheme, "{text}");
273        }
274    }
275
276    #[test]
277    fn a_web_scheme_needs_a_host() {
278        for text in [
279            "https:example.com",
280            "https:/example.com",
281            "https:",
282            "https://",
283            "https:///path",
284            "http://?q",
285            "http://#f",
286            "https://:443/",
287        ] {
288            assert_eq!(refused(text), WebUrlError::Host, "{text}");
289        }
290    }
291
292    #[test]
293    fn characters_a_url_cannot_carry_are_refused_where_they_are() {
294        for (text, found, at) in [
295            (" https://example.com", ' ', 0),
296            ("https://example.com ", ' ', 19),
297            ("https://example.com\n", '\n', 19),
298            ("\thttps://example.com", '\t', 0),
299            ("https://exa\0mple.com", '\0', 11),
300            ("https://example.com/\0.exe", '\0', 20),
301            ("https://example.com/a b", ' ', 21),
302            ("https://example.com/\"--gpu-launcher", '"', 20),
303            (r"https://good.example\@evil.example", '\\', 20),
304            (r"C:\x.exe", '\\', 2),
305            (r"\\server\share\payload.exe", '\\', 0),
306            ("https://exämple.com", 'ä', 10),
307            ("https://example.com/\u{202e}exe.txt", '\u{202e}', 20),
308            ("https://example.com/<script>", '<', 20),
309            ("https://example.com/a|b", '|', 21),
310            ("https://example.com/{x}", '{', 20),
311            ("https://example.com/`x`", '`', 20),
312            ("https://example.com/a^b", '^', 21),
313            ("https://example.com/[x]", '[', 20),
314            ("https://example.com/?q=]", ']', 23),
315            ("https://example.com#[", '[', 20),
316        ] {
317            assert_eq!(
318                refused(text),
319                WebUrlError::Character { found, at },
320                "{text:?}"
321            );
322        }
323    }
324
325    #[test]
326    fn a_percent_must_begin_an_escape() {
327        for (text, at) in [
328            ("https://example.com/%zz", 20),
329            ("https://example.com/%4", 20),
330            ("https://example.com/%", 20),
331            ("https://example.com/%%41", 20),
332            ("https://ex%2", 10),
333        ] {
334            assert_eq!(refused(text), WebUrlError::Escape { at }, "{text}");
335        }
336    }
337
338    #[test]
339    fn user_information_is_refused() {
340        for text in [
341            "https://user@example.com/",
342            "https://user:password@example.com/",
343            "https://bank.example@evil.example/",
344            "https://@example.com",
345        ] {
346            assert_eq!(refused(text), WebUrlError::UserInfo, "{text}");
347        }
348    }
349
350    #[test]
351    fn a_port_is_a_number_a_port_can_be() {
352        for text in [
353            "https://example.com:/",
354            "https://example.com:65536",
355            "https://example.com:99999",
356            "https://example.com:000080",
357            "https://example.com:8a",
358            "https://example.com:+80",
359            "https://example.com:80:80",
360            "https://[::1]:",
361            "https://[::1]:x",
362        ] {
363            assert_eq!(refused(text), WebUrlError::Port, "{text}");
364        }
365    }
366
367    #[test]
368    fn brackets_are_only_an_ipv6_literal() {
369        for text in [
370            "https://[::1",
371            "https://[]/",
372            "https://[example.com]/",
373            "https://[v1.x]/",
374            "https://[127.0.0.1]/",
375            "https://[::1]x/",
376            "https://a]b/",
377            "https://a[b/",
378            "https://example.com:[80]",
379        ] {
380            assert_eq!(refused(text), WebUrlError::Host, "{text}");
381        }
382    }
383
384    #[test]
385    fn errors_say_what_is_wrong() {
386        assert_eq!(
387            refused("https://example.com/a b").to_string(),
388            "' ' at byte 21 must be percent-encoded in a URL"
389        );
390        assert_eq!(
391            refused("file:///x").to_string(),
392            "only http and https URLs can be opened"
393        );
394    }
395}