//! net/url-compatible parsing for permissions and HTTP paths. The HTTP request //! keeps escaped path bytes; browser-style normalization would change signatures. use std::net::Ipv6Addr; #[derive(Clone, Debug, Default)] pub struct UrlParts { pub scheme: String, pub hostname: String, pub path: Vec, pub raw_path: String, pub raw_query: String, pub force_query: bool, pub fragment: String, pub port: Option, pub has_credentials: bool, pub host: Vec, pub username: Vec, pub password: Option>, pub opaque: String, pub omit_host: bool, } impl UrlParts { pub fn parse(input: &str) -> Option { let (input, fragment) = input.split_once('#').unwrap_or((input, "")); decode(fragment, Escape::Path)?; if input.bytes().any(|byte| byte < 32 || byte == 127) { return None; } let mut scheme = ""; let mut rest = input; for (index, byte) in input.bytes().enumerate() { if byte == b':' { if index == 0 { return None; } scheme = &input[..index]; rest = &input[index + 1..]; break; } if !byte.is_ascii_alphabetic() && !(index > 0 && (byte.is_ascii_digit() || b"+-.".contains(&byte))) { break; } } let (path, query) = rest.split_once('?').unwrap_or((rest, "")); let force_query = rest.ends_with('?') && query.is_empty(); rest = path; let mut hostname = String::new(); let mut port = None; let mut has_credentials = false; let mut raw_host = Vec::new(); let mut username = Vec::new(); let mut password = None; if !rest.starts_with('/') { if !scheme.is_empty() { return Some(Self { scheme: scheme.to_lowercase(), hostname, path: vec![], raw_path: rest.to_owned(), raw_query: query.to_owned(), force_query, fragment: fragment.to_owned(), port, has_credentials, opaque: rest.to_owned(), ..Default::default() }); } if rest.split('/').next()?.contains(':') { return None; } } if rest.starts_with("//") && (!scheme.is_empty() || !rest.starts_with("///")) { let authority_and_path = &rest[2..]; let end = authority_and_path .find('/') .unwrap_or(authority_and_path.len()); let authority = &authority_and_path[..end]; rest = &authority_and_path[end..]; let host = if let Some((user, host)) = authority.rsplit_once('@') { has_credentials = true; if !user.bytes().all(|byte| { byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:%@".contains(&byte) }) { return None; } decode(user, Escape::Path)?; let (name, secret) = user .split_once(':') .map_or((user, None), |(name, secret)| (name, Some(secret))); username = decode(name, Escape::Path)?; password = match secret { Some(secret) => Some(decode(secret, Escape::Path)?), None => None, }; host } else { authority }; hostname = parse_host(host, scheme)?; raw_host = decode(host, Escape::Path)?; let port_part = if host.starts_with('[') { &host[host.rfind(']')? + 1..] } else { host.rfind(':').map_or("", |index| &host[index..]) }; port = port_part.strip_prefix(':').map(str::to_owned); } let omit_host = !scheme.is_empty() && path.starts_with('/') && !path.starts_with("//"); let path = if rest.is_empty() { b"/".to_vec() } else { decode(rest, Escape::Path)? }; Some(Self { scheme: scheme.to_lowercase(), hostname, path, raw_path: rest.to_owned(), raw_query: query.to_owned(), force_query, fragment: fragment.to_owned(), port, has_credentials, host: raw_host, username, password, opaque: String::new(), omit_host, }) } /// Serialize a parsed reference without the HTTP layer's credential removal /// or empty-path normalization. pub fn reference_string(&self) -> String { let mut result = String::new(); if !self.scheme.is_empty() { result.push_str(&self.scheme); result.push(':'); } if !self.opaque.is_empty() { result.push_str(&self.opaque); } else { if (!self.scheme.is_empty() || !self.host.is_empty() || self.has_credentials) && !(self.omit_host && self.host.is_empty() && !self.has_credentials) { if !self.host.is_empty() || !self.raw_path.is_empty() || self.has_credentials { result.push_str("//"); } if self.has_credentials { result.push_str(&escape_component(&self.username, b"$&+,;=")); if let Some(password) = &self.password { result.push(':'); result.push_str(&escape_component(password, b"$&+,;=")); } result.push('@'); } result.push_str(&escape_component(&self.host, b"!$&'()*+,;=:[]<>\"")); } let path = self.escaped_path(); if !path.is_empty() && !path.starts_with('/') && !self.host.is_empty() { result.push('/'); } if result.is_empty() && path.split('/').next().is_some_and(|p| p.contains(':')) { result.push_str("./"); } result.push_str(&path); } if self.force_query || !self.raw_query.is_empty() { result.push('?'); result.push_str(&self.raw_query); } if !self.fragment.is_empty() { result.push('#'); if self.fragment.bytes().all(|byte| { byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:[]/%@?".contains(&byte) }) { result.push_str(&self.fragment); } else { result.push_str(&escape_component( &unescape_path(&self.fragment).unwrap_or_default(), b"!$&()*+,/:;=?@", )); } } result } /// Go ResolveReference semantics for JavaScript URL construction, including /// credentials and opaque schemes that cannot be used as HTTP requests. pub fn resolve_reference(&self, reference: &str) -> Option { let mut target = Self::parse(reference)?; let absolute = !target.scheme.is_empty() || !target.host.is_empty() || target.has_credentials; if target.scheme.is_empty() { target.scheme.clone_from(&self.scheme); } if absolute { if target.opaque.is_empty() { target.raw_path = resolve_path(&target.escaped_path(), ""); } } else { if target.raw_path.is_empty() && !target.force_query && target.raw_query.is_empty() { target.raw_query.clone_from(&self.raw_query); if target.fragment.is_empty() { target.fragment.clone_from(&self.fragment); } } if target.raw_path.is_empty() && !self.opaque.is_empty() { target.opaque.clone_from(&self.opaque); } else { target.host.clone_from(&self.host); target.hostname.clone_from(&self.hostname); target.port.clone_from(&self.port); target.has_credentials = self.has_credentials; target.username.clone_from(&self.username); target.password.clone_from(&self.password); let base = if self.opaque.is_empty() { self.escaped_path() } else { String::new() }; target.raw_path = resolve_path(&base, &target.escaped_path()); } } // URL(base) reparses the resolved string in the legacy runtime. Self::parse(&target.reference_string()) } pub fn authority(&self) -> String { let host = if self.hostname.contains(':') { format!("[{}]", self.hostname) } else { self.hostname.clone() }; match &self.port { Some(port) if !port.is_empty() => format!("{host}:{port}"), _ => host, } } pub fn display_url(&self) -> String { let mut result = format!( "{}://{}{}", self.scheme, escape(&self.authority(), true), self.escaped_path() ); if self.force_query || !self.raw_query.is_empty() { result.push('?'); result.push_str(&self.raw_query); } if !self.fragment.is_empty() { result.push('#'); result.push_str(&escape(&self.fragment, false)); } result } pub fn escaped_path(&self) -> String { if self .raw_path .bytes() .all(|byte| byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:[]/%@".contains(&byte)) { return self.raw_path.clone(); } // net/url ignores RawPath if it contains unescaped Unicode/spaces. // Re-encode the decoded path as a whole, including percent escapes. let mut result = String::new(); for byte in decode(&self.raw_path, Escape::Path).unwrap_or_default() { if byte.is_ascii_alphanumeric() || b"-._~$&+,/:;=@".contains(&byte) { result.push(char::from(byte)); } else { use std::fmt::Write; let _ = write!(result, "%{byte:02X}"); } } result } pub fn request_uri(&self) -> Result { if let Some(port) = self.port.as_deref().filter(|port| !port.is_empty()) { port.parse::() .map_err(|_| "invalid URL port".to_owned())?; } let hostname = if self.hostname.contains(':') { format!("[{}]", self.hostname) } else { // URL's domain parser supplies IDNA; the path is deliberately not // passed through its browser-style path parser. ::url::Host::parse(&self.hostname) .map_err(|error| error.to_string())? .to_string() }; let authority = match self.port.as_deref().filter(|port| !port.is_empty()) { Some(port) => format!("{hostname}:{port}"), None => hostname, }; let mut path = if self.raw_path.is_empty() { "/".to_owned() } else { self.escaped_path() }; if self.force_query || !self.raw_query.is_empty() { path.push('?'); path.push_str(&self.raw_query); } http::Uri::builder() .scheme(self.scheme.as_str()) .authority(authority) .path_and_query(path) .build() .map_err(|error| error.to_string()) } pub fn resolve(&self, location: &str) -> Option { let mut target = Self::parse(location)?; let absolute = !target.scheme.is_empty() || !target.hostname.is_empty() || target.has_credentials; if target.scheme.is_empty() { target.scheme.clone_from(&self.scheme); } if absolute { target.raw_path = resolve_path(&target.escaped_path(), ""); } else { if target.raw_path.is_empty() && !target.force_query && target.raw_query.is_empty() { target.raw_query.clone_from(&self.raw_query); if target.fragment.is_empty() { target.fragment.clone_from(&self.fragment); } } target.hostname.clone_from(&self.hostname); target.port.clone_from(&self.port); target.has_credentials = self.has_credentials; target.raw_path = resolve_path(&self.escaped_path(), &target.escaped_path()); } target.path = decode(&target.raw_path, Escape::Path)?; Some(target) } } pub fn unescape_path(input: &str) -> Option> { decode(input, Escape::Path) } fn escape_component(value: &[u8], reserved: &[u8]) -> String { let mut result = String::with_capacity(value.len()); for &byte in value { if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) || reserved.contains(&byte) { result.push(char::from(byte)); } else { use std::fmt::Write; let _ = write!(result, "%{byte:02X}"); } } result } // RFC 3986 dot segments apply to escaped paths. In particular, %2e%2e and // %2f remain escaped rather than becoming browser-style traversal segments. fn resolve_path(base: &str, reference: &str) -> String { let full = if reference.is_empty() { base.to_owned() } else if reference.starts_with('/') { reference.to_owned() } else { format!( "{}{reference}", &base[..base.rfind('/').map_or(0, |index| index + 1)] ) }; if full.is_empty() { return full; } let mut result = String::from("/"); let mut first = true; let mut last = ""; for part in full.split('/') { last = part; match part { "." => first = false, ".." => { result.truncate(result[1..].rfind('/').map_or(1, |index| index + 1)); first = result.len() == 1; } _ => { if !first { result.push('/'); } result.push_str(part); first = false; } } } if last == "." || last == ".." { result.push('/'); } if result.starts_with("//") { result.remove(0); } result } fn escape(value: &str, host: bool) -> String { let mut result = String::with_capacity(value.len()); for byte in value.bytes() { let allowed = byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:[]".contains(&byte) || (!host && b"/%@?".contains(&byte)); if allowed { result.push(char::from(byte)); } else { use std::fmt::Write; let _ = write!(result, "%{byte:02X}"); } } result } fn valid_port(port: &str) -> bool { port.is_empty() || port .strip_prefix(':') .is_some_and(|port| port.bytes().all(|byte| byte.is_ascii_digit())) } fn parse_host(host: &str, scheme: &str) -> Option { if let Some(open) = host.rfind('[') { if open != 0 { return None; } let close = host.rfind(']')?; if !valid_port(&host[close + 1..]) { return None; } let raw = &host[1..close]; let decoded = if let Some((address, zone)) = raw.split_once("%25") { let mut bytes = decode(address, Escape::Host)?; bytes.extend(decode(&format!("%25{zone}"), Escape::Zone)?); bytes } else { decode(raw, Escape::Host)? }; let hostname = String::from_utf8_lossy(&decoded).into_owned(); let address = if let Some((address, zone)) = hostname.split_once('%') { if zone.is_empty() { return None; } address } else { hostname.as_str() }; address.parse::().ok()?; return Some(hostname); } let mut hostname = host; if let Some(first) = host.find(':') { let index = if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") { first } else { host.rfind(':')? }; if !valid_port(&host[index..]) { return None; } hostname = &host[..index]; } Some(String::from_utf8_lossy(&decode(hostname, Escape::Host)?).into_owned()) } #[derive(Clone, Copy)] enum Escape { Path, Host, Zone, } fn valid_host_byte(byte: u8) -> bool { byte >= 128 || byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:[]<>\"".contains(&byte) } fn decode(input: &str, mode: Escape) -> Option> { let mut result = Vec::with_capacity(input.len()); let mut bytes = input.bytes(); while let Some(byte) = bytes.next() { if byte == b'%' { let high = char::from(bytes.next()?).to_digit(16)?; let low = char::from(bytes.next()?).to_digit(16)?; let value = (high * 16 + low) as u8; match mode { Escape::Host if value < 128 && value != b'%' => return None, Escape::Zone if value != b'%' && value != b' ' && !valid_host_byte(value) => { return None; } _ => result.push(value), } } else { if !matches!(mode, Escape::Path) && !valid_host_byte(byte) { return None; } result.push(byte); } } Some(result) } #[cfg(test)] mod tests { use super::*; #[test] fn raw_requests_and_relative_redirect_paths_keep_go_semantics() { let base = UrlParts::parse("https://example.test/a/../b/%2e%2e/x?old#fragment?").unwrap(); assert_eq!( base.request_uri() .unwrap() .path_and_query() .unwrap() .as_str(), "/a/../b/%2e%2e/x?old" ); for (reference, expected) in [ ("../z", "https://example.test/b/z"), ("%2e%2e/z", "https://example.test/b/%2e%2e/%2e%2e/z"), ("/a//b/../c", "https://example.test/a//c"), ("?", "https://example.test/b/%2e%2e/x?"), ("#new?", "https://example.test/b/%2e%2e/x?old#new?"), ] { assert_eq!( base.resolve(reference).unwrap().display_url(), expected, "{reference}" ); } assert_eq!( UrlParts::parse("https://example.test?") .unwrap() .request_uri() .unwrap() .to_string(), "https://example.test/?" ); } }