use percent_encoding::percent_decode_str;
#[derive(Debug)]
pub(crate) struct ParsedUrl {
pub scheme: String, pub username: String, pub password: Option<String>, pub host: Option<String>, pub port: Option<u16>,
pub path: String, pub path_with_percent_escapes: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UrlParseError {
#[error("relative URL without a base")]
RelativeUrlWithoutBase,
#[error("invalid port number - must be between 1-65535")]
InvalidPort,
#[error("invalid domain character")]
InvalidDomainCharacter,
#[error("Scheme requires host")]
SchemeRequiresHost,
}
fn is_valid_scheme_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.'
}
fn has_valid_percent_encoding(input: &str) -> bool {
let mut bytes = input.bytes();
while let Some(byte) = bytes.next() {
if byte == b'%'
&& !matches!(
(bytes.next(), bytes.next()),
(Some(a), Some(b)) if a.is_ascii_hexdigit() && b.is_ascii_hexdigit()
)
{
return false;
}
}
true
}
fn percent_decode(s: &str) -> Result<String, UrlParseError> {
percent_decode_str(s)
.decode_utf8()
.map(std::borrow::Cow::into_owned)
.map_err(|_| UrlParseError::InvalidDomainCharacter)
}
fn percent_decode_path(s: &str) -> Result<(String, Option<String>), UrlParseError> {
percent_decode(s).map(|path| (path, s.contains('%').then(|| s.to_owned())))
}
fn normalize_ipv6_literal(host: &str) -> Option<String> {
let Some((address, zone)) = host.split_once("%25") else {
return host
.parse::<std::net::Ipv6Addr>()
.is_ok()
.then(|| host.to_ascii_lowercase());
};
if address.parse::<std::net::Ipv6Addr>().is_err() || zone.is_empty() || percent_decode(zone).is_err() {
return None;
}
let mut pos = 0;
let bytes = zone.as_bytes();
while pos < bytes.len() {
if bytes[pos].is_ascii_alphanumeric() || matches!(bytes[pos], b'-' | b'.' | b'_' | b'~') {
pos += 1;
} else if bytes[pos] == b'%'
&& bytes.get(pos + 1).is_some_and(u8::is_ascii_hexdigit)
&& bytes.get(pos + 2).is_some_and(u8::is_ascii_hexdigit)
{
pos += 3;
} else {
return None;
}
}
Some(format!("{}%25{zone}", address.to_ascii_lowercase()))
}
impl ParsedUrl {
pub(crate) fn parse(input: &str) -> Result<Self, UrlParseError> {
if input.chars().any(char::is_whitespace) || !has_valid_percent_encoding(input) {
return Err(UrlParseError::InvalidDomainCharacter);
}
let first_colon = input.find(':').ok_or(UrlParseError::RelativeUrlWithoutBase)?;
let scheme_str = &input[..first_colon];
let scheme = scheme_str.to_ascii_lowercase();
let Some(after_scheme) = input[first_colon..].strip_prefix("://") else {
return Err(UrlParseError::RelativeUrlWithoutBase);
};
if scheme_str.is_empty() {
return Err(UrlParseError::RelativeUrlWithoutBase);
}
if !scheme_str.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
|| !scheme_str.chars().all(is_valid_scheme_char)
{
return Err(UrlParseError::RelativeUrlWithoutBase);
}
let path_start = if matches!(scheme.as_str(), "http" | "https") {
after_scheme.find(['/', '?', '#'])
} else {
after_scheme.find('/')
}
.unwrap_or(after_scheme.len());
let authority = &after_scheme[..path_start];
if authority.contains('\\') {
return Err(UrlParseError::InvalidDomainCharacter);
}
let (path, path_with_percent_escapes) = if path_start < after_scheme.len() {
percent_decode_path(&after_scheme[path_start..])?
} else {
(String::new(), None)
};
let allow_unbracketed_ipv6 = matches!(scheme.as_str(), "git" | "ssh" | "git+ssh" | "ssh+git");
let strict_authority = matches!(scheme.as_str(), "http" | "https");
let (username, password, host, port) = if let Some((user_info, host_port)) = authority.rsplit_once('@') {
let (user, pass) = if let Some((user_str, pass_str)) = user_info.split_once(':') {
let pass = if pass_str.is_empty() {
None
} else {
Some(percent_decode(pass_str)?)
};
(percent_decode(user_str)?, pass)
} else {
(percent_decode(user_info)?, None)
};
let (h, p) = Self::parse_host_port(host_port, allow_unbracketed_ipv6, strict_authority)?;
if h.is_none() {
return Err(UrlParseError::InvalidDomainCharacter);
}
(user, pass, h, p)
} else {
let (h, p) = Self::parse_host_port(authority, allow_unbracketed_ipv6, strict_authority)?;
(String::new(), None, h, p)
};
let requires_host = matches!(scheme.as_str(), "http" | "https" | "git" | "ssh" | "ftp" | "ftps");
if requires_host && host.is_none() {
return Err(UrlParseError::SchemeRequiresHost);
}
Ok(ParsedUrl {
scheme,
username,
password,
host,
port,
path,
path_with_percent_escapes,
})
}
fn parse_host_port(
host_port: &str,
allow_unbracketed_ipv6: bool,
strict_authority: bool,
) -> Result<(Option<String>, Option<u16>), UrlParseError> {
if host_port.is_empty() {
return Ok((None, None));
}
if host_port.starts_with('[') {
if let Some(bracket_end) = host_port.find(']') {
let inner = &host_port[1..bracket_end];
let host = match normalize_ipv6_literal(inner) {
Some(host) if !strict_authority => percent_decode(&host)?,
Some(host) => host,
None if !strict_authority => percent_decode(inner)?,
None => return Err(UrlParseError::InvalidDomainCharacter),
};
let remaining = &host_port[bracket_end + 1..];
if remaining.is_empty() {
return Ok((Some(format!("[{host}]")), None));
} else if let Some(port_str) = remaining.strip_prefix(':') {
if port_str.is_empty() {
return Ok((Some(format!("[{host}]:")), None));
}
if !port_str.bytes().all(|b| b.is_ascii_digit()) {
return Err(UrlParseError::InvalidPort);
}
let port = port_str.parse::<u16>().map_err(|_| UrlParseError::InvalidPort)?;
if port == 0 && strict_authority {
return Err(UrlParseError::InvalidPort);
}
return Ok((Some(format!("[{host}]")), Some(port)));
} else {
return Err(UrlParseError::InvalidDomainCharacter);
}
} else {
return Err(UrlParseError::InvalidDomainCharacter);
}
}
if allow_unbracketed_ipv6
&& (host_port.parse::<std::net::Ipv6Addr>().is_ok()
|| host_port
.strip_suffix(':')
.is_some_and(|host| host.parse::<std::net::Ipv6Addr>().is_ok()))
{
return Ok((Some(host_port.to_ascii_lowercase()), None));
}
if let Some((before_last_colon, after_last_colon)) = host_port.rsplit_once(':') {
if before_last_colon.is_empty() || before_last_colon.contains(':') {
return if strict_authority {
Err(UrlParseError::InvalidDomainCharacter)
} else {
Ok((Some(Self::normalize_git_hostname(host_port)?), None))
};
}
if after_last_colon.is_empty() {
let mut host = if strict_authority {
Self::normalize_http_hostname(before_last_colon)?
} else {
Self::normalize_git_hostname(before_last_colon)?
};
host.push(':');
return Ok((Some(host), None));
}
if !after_last_colon.chars().all(|c| c.is_ascii_digit()) {
return Err(UrlParseError::InvalidPort);
}
let host = if strict_authority {
Self::normalize_http_hostname(before_last_colon)?
} else {
Self::normalize_git_hostname(before_last_colon)?
};
let port = after_last_colon
.parse::<u16>()
.map_err(|_| UrlParseError::InvalidPort)?;
if port == 0 && strict_authority {
return Err(UrlParseError::InvalidPort);
}
return Ok((Some(host), Some(port)));
}
let host = if strict_authority {
Self::normalize_http_hostname(host_port)?
} else {
Self::normalize_git_hostname(host_port)?
};
Ok((Some(host), None))
}
fn is_normalizable_hostname(host: &str) -> bool {
host.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'.' | b'_' | b'*'))
}
fn normalize_http_hostname(host: &str) -> Result<String, UrlParseError> {
if !host.bytes().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
b'-' | b'.'
| b'_'
| b'~'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b'%'
)
}) {
return Err(UrlParseError::InvalidDomainCharacter);
}
Ok(if Self::is_normalizable_hostname(host) {
host.to_ascii_lowercase()
} else {
host.to_owned()
})
}
fn normalize_git_hostname(host: &str) -> Result<String, UrlParseError> {
let host = percent_decode(host)?;
Ok(if Self::is_normalizable_hostname(&host) {
host.to_ascii_lowercase()
} else {
host
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_url() {
let url = ParsedUrl::parse("http://example.com/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.path, "/path");
assert_eq!(url.username, "");
assert_eq!(url.password, None);
assert_eq!(url.port, None);
}
#[test]
fn url_with_port() {
let url = ParsedUrl::parse("http://example.com:8080/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.port, Some(8080));
assert_eq!(url.path, "/path");
}
#[test]
fn url_with_user() {
let url = ParsedUrl::parse("http://user@example.com/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.username, "user");
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.path, "/path");
}
#[test]
fn url_with_user_and_password() {
let url = ParsedUrl::parse("http://user:pass@example.com/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.username, "user");
assert_eq!(url.password.as_deref(), Some("pass"));
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.path, "/path");
}
#[test]
fn url_with_ipv6() {
let url = ParsedUrl::parse("http://[::1]/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.host.as_deref(), Some("[::1]"));
assert_eq!(url.path, "/path");
}
#[test]
fn url_with_ipv6_and_port() {
let url = ParsedUrl::parse("http://[::1]:8080/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.host.as_deref(), Some("[::1]"));
assert_eq!(url.port, Some(8080));
assert_eq!(url.path, "/path");
}
#[test]
fn git_schemes_allow_unbracketed_ipv6() {
for scheme in ["git", "ssh", "git+ssh", "ssh+git"] {
let url = ParsedUrl::parse(&format!("{scheme}://user@::1/repo"))
.expect("Git schemes allow unbracketed IPv6 hosts");
assert_eq!(url.host.as_deref(), Some("::1"), "the IPv6 address is the host");
assert_eq!(url.path, "/repo", "the path remains separate from the IPv6 host");
}
}
#[test]
fn malformed_authorities_are_rejected() {
for (url, message) in [
(
r"http://redirected.example\@original.example/repo",
"backslashes in the authority must be rejected",
),
("http://example.com:abc/", "non-numeric ports must be rejected"),
("http://foo:bar:baz/", "unbracketed colons must be rejected"),
("http://[not-ip]/", "bracketed hosts must be valid IPv6 addresses"),
("http://[fe80::1%25]/repo", "IPv6 zone identifiers must not be empty"),
(
"http://[fe80::1%25eth!0]/repo",
"IPv6 zone identifiers contain only unreserved or percent-encoded characters",
),
("http://bücher.example/", "non-ASCII hostnames must be rejected"),
("http://::1/", "unbracketed IPv6 addresses must be rejected for HTTP"),
] {
assert!(ParsedUrl::parse(url).is_err(), "{message}");
}
}
#[test]
fn utf8_user_information_is_accepted() {
let url = ParsedUrl::parse("ssh://jörg:passwörd@example.com/repo").expect("valid UTF-8 user information");
assert_eq!(url.username, "jörg", "the username is preserved");
assert_eq!(url.password.as_deref(), Some("passwörd"), "the password is preserved");
}
#[test]
fn malformed_schemes_and_percent_escapes_are_rejected() {
for url in [
"1http://example.com/",
"http://example.com/%",
"http://example.com/%2",
"http://example.com/%zz",
"http://user%zz@example.com/",
"http://example%zz.com/",
] {
assert!(ParsedUrl::parse(url).is_err(), "invalid URL {url:?} must be rejected");
}
assert!(
ParsedUrl::parse("http://example.com/%2f").is_ok(),
"hex escapes are valid"
);
}
#[test]
fn url_with_space_in_host_is_rejected() {
assert!(ParsedUrl::parse("http://has a space").is_err());
assert!(ParsedUrl::parse("http://has a space/path").is_err());
assert!(ParsedUrl::parse("https://example.com with space/path").is_err());
}
#[test]
fn url_with_tab_in_host_is_rejected() {
assert!(ParsedUrl::parse("http://has\ta\ttab").is_err());
}
#[test]
fn url_with_newline_in_host_is_rejected() {
assert!(ParsedUrl::parse("http://has\na\nnewline").is_err());
}
#[test]
fn url_with_percent_encoded_username() {
let url = ParsedUrl::parse("http://user%20name@example.com/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.username, "user name");
assert_eq!(url.password, None);
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.path, "/path");
}
#[test]
fn url_with_percent_encoded_password() {
let url = ParsedUrl::parse("http://user:pass%20word@example.com/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.username, "user");
assert_eq!(url.password.as_deref(), Some("pass word"));
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.path, "/path");
}
#[test]
fn url_with_percent_encoded_username_and_password() {
let url = ParsedUrl::parse("http://user%20name:pass%20word@example.com/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.username, "user name");
assert_eq!(url.password.as_deref(), Some("pass word"));
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.path, "/path");
}
#[test]
fn url_with_special_chars_in_username() {
let url = ParsedUrl::parse("http://user%40name@example.com/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.username, "user@name");
assert_eq!(url.password, None);
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.path, "/path");
}
#[test]
fn url_with_special_chars_in_password() {
let url = ParsedUrl::parse("http://user:p%40ss%3Aword@example.com/path").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.username, "user");
assert_eq!(url.password.as_deref(), Some("p@ss:word"));
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.path, "/path");
}
#[test]
fn url_with_percent_encoded_path() {
let url = ParsedUrl::parse("http://example.com/path/with%20spaces/file").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.host.as_deref(), Some("example.com"));
assert_eq!(url.path, "/path/with spaces/file");
}
}