use core::fmt;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct WebUrl(String);
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum WebUrlError {
#[error("{found:?} at byte {at} must be percent-encoded in a URL")]
Character {
found: char,
at: usize,
},
#[error("the % at byte {at} does not start a percent escape")]
Escape {
at: usize,
},
#[error("only http and https URLs can be opened")]
Scheme,
#[error("the URL has no host, or a malformed one")]
Host,
#[error("a URL with a user name or password before the host is refused")]
UserInfo,
#[error("the URL's port is not a number from 0 to 65535")]
Port,
}
impl WebUrl {
pub fn parse(text: &str) -> Result<Self, WebUrlError> {
check_characters(text)?;
let (scheme, rest) = text.split_once(':').ok_or(WebUrlError::Scheme)?;
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
return Err(WebUrlError::Scheme);
}
let rest = rest.strip_prefix("//").ok_or(WebUrlError::Host)?;
let (authority, tail) = rest.split_at(rest.find(['/', '?', '#']).unwrap_or(rest.len()));
check_authority(authority)?;
let tail_start = text.len() - tail.len();
if let Some((offset, found)) = tail.char_indices().find(|&(_, c)| c == '[' || c == ']') {
return Err(WebUrlError::Character {
found,
at: tail_start + offset,
});
}
Ok(Self(text.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for WebUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
fn check_characters(text: &str) -> Result<(), WebUrlError> {
let bytes = text.as_bytes();
for (at, found) in text.char_indices() {
match found {
'%' => {
let escape = bytes.get(at + 1..at + 3);
if !escape.is_some_and(|digits| digits.iter().all(u8::is_ascii_hexdigit)) {
return Err(WebUrlError::Escape { at });
}
}
'A'..='Z' | 'a'..='z' | '0'..='9' => {}
'-' | '.' | '_' | '~' => {}
':' | '/' | '?' | '#' | '[' | ']' | '@' => {}
'!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' => {}
_ => return Err(WebUrlError::Character { found, at }),
}
}
Ok(())
}
fn check_authority(authority: &str) -> Result<(), WebUrlError> {
if authority.contains('@') {
return Err(WebUrlError::UserInfo);
}
let port = if let Some(literal) = authority.strip_prefix('[') {
let (address, after) = literal.split_once(']').ok_or(WebUrlError::Host)?;
let is_ipv6 = address.contains(':')
&& address
.bytes()
.all(|byte| byte.is_ascii_hexdigit() || byte == b':' || byte == b'.');
if !is_ipv6 {
return Err(WebUrlError::Host);
}
if after.is_empty() {
None
} else {
Some(after.strip_prefix(':').ok_or(WebUrlError::Host)?)
}
} else {
if authority.contains(['[', ']']) {
return Err(WebUrlError::Host);
}
let (host, port) = authority
.split_once(':')
.map_or((authority, None), |(host, port)| (host, Some(port)));
if host.is_empty() {
return Err(WebUrlError::Host);
}
port
};
match port {
None => Ok(()),
Some(port)
if (1..=5).contains(&port.len())
&& port.bytes().all(|byte| byte.is_ascii_digit())
&& port.parse::<u32>().is_ok_and(|number| number <= 65535) =>
{
Ok(())
}
Some(_) => Err(WebUrlError::Port),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn refused(text: &str) -> WebUrlError {
WebUrl::parse(text).expect_err(text)
}
#[test]
fn web_urls_are_accepted_verbatim() {
for text in [
"http://example.com",
"https://example.com/",
"https://store.example/app/1234?ref=menu&x=1#reviews",
"HTTPS://EXAMPLE.COM/PATH",
"HtTp://example.com",
"https://example.com:443/",
"http://example.com:0",
"https://example.com:65535/x",
"https://127.0.0.1:8080/",
"https://[::1]/",
"https://[2001:db8::7]:8443/a?b#c",
"https://[::ffff:192.0.2.1]",
"https://example.com/a%20b/%C3%A9?q=%2F",
"https://example.com?query",
"https://example.com#fragment",
"https://example.com/!$&'()*+,;=:@-._~",
"https://xn--bcher-kva.example/",
] {
let url = WebUrl::parse(text).unwrap_or_else(|error| panic!("{text}: {error}"));
assert_eq!(url.as_str(), text, "kept as given");
assert_eq!(url.to_string(), text);
}
}
#[test]
fn other_schemes_are_refused() {
for text in [
"file:///C:/Windows/System32/calc.exe",
"FILE://server/share/x.lnk",
"javascript:alert(1)",
"ms-msdt:/id",
"search-ms:query=x",
"shell:startup",
"about:blank",
"ftp://example.com/",
"httpx://example.com",
"https+x://example.com",
"data:text/html,x",
"mailto:someone@example.com",
"C:/game/patch.exe",
"://example.com",
"",
"example.com",
"/usr/bin/xterm",
"relative/path",
] {
assert_eq!(refused(text), WebUrlError::Scheme, "{text}");
}
}
#[test]
fn a_web_scheme_needs_a_host() {
for text in [
"https:example.com",
"https:/example.com",
"https:",
"https://",
"https:///path",
"http://?q",
"http://#f",
"https://:443/",
] {
assert_eq!(refused(text), WebUrlError::Host, "{text}");
}
}
#[test]
fn characters_a_url_cannot_carry_are_refused_where_they_are() {
for (text, found, at) in [
(" https://example.com", ' ', 0),
("https://example.com ", ' ', 19),
("https://example.com\n", '\n', 19),
("\thttps://example.com", '\t', 0),
("https://exa\0mple.com", '\0', 11),
("https://example.com/\0.exe", '\0', 20),
("https://example.com/a b", ' ', 21),
("https://example.com/\"--gpu-launcher", '"', 20),
(r"https://good.example\@evil.example", '\\', 20),
(r"C:\x.exe", '\\', 2),
(r"\\server\share\payload.exe", '\\', 0),
("https://exämple.com", 'ä', 10),
("https://example.com/\u{202e}exe.txt", '\u{202e}', 20),
("https://example.com/<script>", '<', 20),
("https://example.com/a|b", '|', 21),
("https://example.com/{x}", '{', 20),
("https://example.com/`x`", '`', 20),
("https://example.com/a^b", '^', 21),
("https://example.com/[x]", '[', 20),
("https://example.com/?q=]", ']', 23),
("https://example.com#[", '[', 20),
] {
assert_eq!(
refused(text),
WebUrlError::Character { found, at },
"{text:?}"
);
}
}
#[test]
fn a_percent_must_begin_an_escape() {
for (text, at) in [
("https://example.com/%zz", 20),
("https://example.com/%4", 20),
("https://example.com/%", 20),
("https://example.com/%%41", 20),
("https://ex%2", 10),
] {
assert_eq!(refused(text), WebUrlError::Escape { at }, "{text}");
}
}
#[test]
fn user_information_is_refused() {
for text in [
"https://user@example.com/",
"https://user:password@example.com/",
"https://bank.example@evil.example/",
"https://@example.com",
] {
assert_eq!(refused(text), WebUrlError::UserInfo, "{text}");
}
}
#[test]
fn a_port_is_a_number_a_port_can_be() {
for text in [
"https://example.com:/",
"https://example.com:65536",
"https://example.com:99999",
"https://example.com:000080",
"https://example.com:8a",
"https://example.com:+80",
"https://example.com:80:80",
"https://[::1]:",
"https://[::1]:x",
] {
assert_eq!(refused(text), WebUrlError::Port, "{text}");
}
}
#[test]
fn brackets_are_only_an_ipv6_literal() {
for text in [
"https://[::1",
"https://[]/",
"https://[example.com]/",
"https://[v1.x]/",
"https://[127.0.0.1]/",
"https://[::1]x/",
"https://a]b/",
"https://a[b/",
"https://example.com:[80]",
] {
assert_eq!(refused(text), WebUrlError::Host, "{text}");
}
}
#[test]
fn errors_say_what_is_wrong() {
assert_eq!(
refused("https://example.com/a b").to_string(),
"' ' at byte 21 must be percent-encoded in a URL"
);
assert_eq!(
refused("file:///x").to_string(),
"only http and https URLs can be opened"
);
}
}