Files
SpotiFLAC-Mobile/rust_backend/crates/network/src/url.rs
T

550 lines
19 KiB
Rust

//! 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<u8>,
pub raw_path: String,
pub raw_query: String,
pub force_query: bool,
pub fragment: String,
pub port: Option<String>,
pub has_credentials: bool,
pub host: Vec<u8>,
pub username: Vec<u8>,
pub password: Option<Vec<u8>>,
pub opaque: String,
pub omit_host: bool,
}
impl UrlParts {
pub fn parse(input: &str) -> Option<Self> {
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<Self> {
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<http::Uri, String> {
if let Some(port) = self.port.as_deref().filter(|port| !port.is_empty()) {
port.parse::<u16>()
.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<Self> {
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<Vec<u8>> {
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<String> {
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::<Ipv6Addr>().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<Vec<u8>> {
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/?"
);
}
}