#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Url {
pub raw: String,
pub scheme: String,
pub host: String,
pub port: Option<u16>,
pub path: String,
}
pub fn parse(s: &str) -> Option<Url> {
let (scheme, rest) = s.split_once("://")?;
if scheme.is_empty() || !is_valid_scheme(scheme) {
return None;
}
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
if authority.is_empty() {
return None;
}
let authority = match authority.rfind('@') {
Some(i) => &authority[i + 1..],
None => authority,
};
let (host, port) = split_host_port(authority)?;
if host.is_empty() {
return None;
}
Some(Url {
raw: s.to_string(),
scheme: scheme.to_ascii_lowercase(),
host: host.to_ascii_lowercase(),
port,
path: path.to_string(),
})
}
fn is_valid_scheme(scheme: &str) -> bool {
let mut chars = scheme.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_alphabetic() {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
}
fn split_host_port(authority: &str) -> Option<(&str, Option<u16>)> {
if authority.starts_with('[') {
let end = authority.find(']')?;
let host = &authority[..=end];
let rest = &authority[end + 1..];
let port = parse_port_suffix(rest)?;
return Some((host, port));
}
match authority.rfind(':') {
Some(i) => {
let host = &authority[..i];
let port: u16 = authority[i + 1..].parse().ok()?;
Some((host, Some(port)))
},
None => Some((authority, None)),
}
}
fn parse_port_suffix(rest: &str) -> Option<Option<u16>> {
if rest.is_empty() {
return Some(None);
}
let port_str = rest.strip_prefix(':')?;
let port: u16 = port_str.parse().ok()?;
Some(Some(port))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_https_and_http_url_shapes() {
let cases = [
(
"https://example.com/foo",
"https",
"example.com",
None,
"/foo",
),
("http://example.com", "http", "example.com", None, ""),
(
"http://localhost:8080/admin",
"http",
"localhost",
Some(8080),
"/admin",
),
(
"http://169.254.169.254/latest/meta-data/",
"http",
"169.254.169.254",
None,
"/latest/meta-data/",
),
];
for (raw, scheme, host, port, path) in cases {
let u = parse(raw).unwrap();
assert_eq!(u.scheme, scheme, "raw={raw}");
assert_eq!(u.host, host, "raw={raw}");
assert_eq!(u.port, port, "raw={raw}");
assert_eq!(u.path, path, "raw={raw}");
assert_eq!(u.raw, raw);
}
}
#[test]
fn lowercases_scheme_and_host() {
let u = parse("HTTPS://EXAMPLE.COM/Path").unwrap();
assert_eq!(u.scheme, "https");
assert_eq!(u.host, "example.com");
assert_eq!(u.path, "/Path");
}
#[test]
fn strips_userinfo_from_authority() {
let u = parse("https://user:pass@example.com/x").unwrap();
assert_eq!(u.host, "example.com");
assert_eq!(u.path, "/x");
}
#[test]
fn parses_ipv6_literal_with_port() {
let u = parse("http://[::1]:8080/").unwrap();
assert_eq!(u.host, "[::1]");
assert_eq!(u.port, Some(8080));
assert_eq!(u.path, "/");
}
#[test]
fn parses_ipv6_literal_without_port() {
let u = parse("http://[::1]/").unwrap();
assert_eq!(u.host, "[::1]");
assert_eq!(u.port, None);
}
#[test]
fn rejects_missing_scheme() {
assert!(parse("example.com/foo").is_none());
assert!(parse("//example.com/foo").is_none());
}
#[test]
fn rejects_invalid_scheme_chars() {
assert!(parse("1http://example.com").is_none());
assert!(parse("ht tp://example.com").is_none());
}
#[test]
fn rejects_empty_authority() {
assert!(parse("http:///foo").is_none());
}
#[test]
fn rejects_malformed_port() {
assert!(parse("http://example.com:notaport/").is_none());
assert!(parse("http://[::1]:abc/").is_none());
}
use crate::testing::proptest::web_url;
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_parse_never_panics(s in web_url()) {
let _ = parse(&s);
}
#[test]
fn pbt_parse_arbitrary_never_panics(s in "[ -~]{0,80}") {
let _ = parse(&s);
}
#[test]
fn pbt_scheme_and_host_are_lowercased(s in web_url()) {
if let Some(u) = parse(&s) {
prop_assert_eq!(&u.scheme, &u.scheme.to_ascii_lowercase());
prop_assert_eq!(&u.host, &u.host.to_ascii_lowercase());
}
}
#[test]
fn pbt_raw_round_trips(s in web_url()) {
if let Some(u) = parse(&s) {
prop_assert_eq!(u.raw, s);
}
}
#[test]
fn pbt_userinfo_stripped_from_host(
user in "[a-z][a-z0-9]{0,8}",
pass in "[a-zA-Z0-9_]{1,12}",
host in "[a-z][a-z0-9-]{1,16}\\.com",
) {
let s = format!("https://{user}:{pass}@{host}/x");
let u = parse(&s).expect("parse");
prop_assert!(!u.host.contains('@'));
prop_assert_eq!(u.host, host.to_ascii_lowercase());
}
#[test]
fn pbt_canonical_urls_round_trip(
scheme in "(?:https?|ftp)",
host in "[a-z][a-z0-9.-]{1,16}",
path in "[a-z0-9/._-]{0,16}",
) {
let s = format!("{scheme}://{host}/{path}");
let u = parse(&s).expect("parse");
prop_assert_eq!(u.scheme, scheme.to_ascii_lowercase());
prop_assert_eq!(u.host, host.to_ascii_lowercase());
prop_assert_eq!(u.path, format!("/{path}"));
prop_assert_eq!(u.port, None);
}
#[test]
fn pbt_port_round_trips(port in 0u16..=65_535) {
let s = format!("http://example.com:{port}/x");
let u = parse(&s).expect("parse");
prop_assert_eq!(u.port, Some(port));
}
#[test]
fn pbt_no_scheme_separator_fails(s in "[^:]{0,40}") {
prop_assume!(!s.contains("://"));
prop_assert!(parse(&s).is_none());
}
}
}