mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
feat(backend): migrate production backend to Rust
Replace the Go backend with the Rust workspace and route Android/iOS through UniFFI bindings. Include the migrated extension runtime, network, providers, media metadata, downloads and library operations, with version 5.0.0+147. Remove Go sources, adapters, native build selection and CI dependencies. Build Rust unconditionally and lock the iOS Rust pod using a relative path. Retain legacy source and migration evidence in a local ignored archive. Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI build checkpoint; no new APK or iOS application build for this cleanup. Known follow-up: URL/URLSearchParams globals are missing from the Rust JS runtime. A controlled extension replay confirms a URL-resolution regression; this commit does not fix that runtime gap.
This commit is contained in:
1 parent
1da0da18a6
commit
aa99726439
452 files changed
+64417
-65088
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,238 @@
|
||||
use crate::url::UrlParts;
|
||||
use cookie::Cookie;
|
||||
use cookie::time::OffsetDateTime;
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CookieJar {
|
||||
entries: BTreeMap<(String, String, Vec<u8>, String), Entry>,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
value: String,
|
||||
host_only: bool,
|
||||
secure: bool,
|
||||
expires: Option<OffsetDateTime>,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
impl CookieJar {
|
||||
pub(crate) fn store(&mut self, url: &UrlParts, headers: &http::HeaderMap) {
|
||||
let host = canonical_host(url);
|
||||
let partition = jar_key(&host);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
// Go's default cookie parser rejects the entire batch over this limit.
|
||||
if headers.get_all(http::header::SET_COOKIE).iter().count() > 3000 {
|
||||
return;
|
||||
}
|
||||
for header in headers.get_all(http::header::SET_COOKIE) {
|
||||
let Ok(raw) = header.to_str() else { continue };
|
||||
let Ok(cookie) = Cookie::parse(raw) else {
|
||||
continue;
|
||||
};
|
||||
if http::HeaderName::from_bytes(cookie.name().as_bytes()).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Some((_, raw_value)) = raw.split(';').next().and_then(|pair| pair.split_once('='))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let raw_value = raw_value.trim();
|
||||
let quoted =
|
||||
raw_value.starts_with('"') && raw_value.ends_with('"') && raw_value.len() >= 2;
|
||||
let value = if quoted {
|
||||
&raw_value[1..raw_value.len() - 1]
|
||||
} else {
|
||||
raw_value
|
||||
};
|
||||
if !value
|
||||
.bytes()
|
||||
.all(|byte| (32..127).contains(&byte) && !b"\";\\".contains(&byte))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Keep the unstripped Domain attribute: Go rejects .IP and ..host.
|
||||
let raw_domain = raw
|
||||
.split(';')
|
||||
.skip(1)
|
||||
.filter_map(|attribute| attribute.trim().split_once('='))
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("domain"))
|
||||
.map(|(_, value)| value.trim())
|
||||
.last()
|
||||
.unwrap_or("");
|
||||
let (domain, host_only) = if raw_domain.is_empty() {
|
||||
(host.clone(), true)
|
||||
} else if host.parse::<IpAddr>().is_ok() || host.contains(':') {
|
||||
if raw_domain != host {
|
||||
continue;
|
||||
}
|
||||
(host.clone(), true)
|
||||
} else {
|
||||
let domain = raw_domain
|
||||
.strip_prefix('.')
|
||||
.unwrap_or(raw_domain)
|
||||
.to_ascii_lowercase();
|
||||
if domain.is_empty()
|
||||
|| !domain.is_ascii()
|
||||
|| domain.starts_with('.')
|
||||
|| domain.ends_with('.')
|
||||
|| (host != domain && !host.ends_with(&format!(".{domain}")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
(domain, false)
|
||||
};
|
||||
let path = cookie
|
||||
.path()
|
||||
.filter(|path| path.starts_with('/'))
|
||||
.map(|path| path.as_bytes().to_vec())
|
||||
.unwrap_or_else(|| default_path(&url.path));
|
||||
let key = (partition.clone(), domain, path, cookie.name().to_owned());
|
||||
let max_age = raw
|
||||
.split(';')
|
||||
.skip(1)
|
||||
.filter_map(|attribute| attribute.trim().split_once('='))
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("max-age"))
|
||||
.filter_map(|(_, value)| {
|
||||
let value = value.trim();
|
||||
let seconds = value.parse::<isize>().ok()?;
|
||||
if seconds != 0 && value.starts_with('0') {
|
||||
None
|
||||
} else {
|
||||
Some(seconds)
|
||||
}
|
||||
})
|
||||
.last();
|
||||
let expires = match max_age {
|
||||
Some(seconds) if seconds <= 0 => Some(now),
|
||||
Some(seconds) => now.checked_add(cookie::time::Duration::seconds(seconds as i64)),
|
||||
None => cookie.expires_datetime(),
|
||||
};
|
||||
if expires.is_some_and(|expires| expires <= now) {
|
||||
self.entries.remove(&key);
|
||||
continue;
|
||||
}
|
||||
let sequence = self.entries.get(&key).map_or_else(
|
||||
|| {
|
||||
let sequence = self.sequence;
|
||||
self.sequence += 1;
|
||||
sequence
|
||||
},
|
||||
|entry| entry.sequence,
|
||||
);
|
||||
let value = if quoted || value.contains([' ', ',']) {
|
||||
format!("\"{value}\"")
|
||||
} else {
|
||||
value.to_owned()
|
||||
};
|
||||
self.entries.insert(
|
||||
key,
|
||||
Entry {
|
||||
value,
|
||||
host_only,
|
||||
secure: cookie.secure().unwrap_or(false),
|
||||
expires,
|
||||
sequence,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn header(&mut self, url: &UrlParts) -> String {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
self.entries
|
||||
.retain(|_, entry| entry.expires.is_none_or(|expires| expires > now));
|
||||
let host = canonical_host(url);
|
||||
let partition = jar_key(&host);
|
||||
let mut selected: Vec<_> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|((jar, domain, path, _), entry)| {
|
||||
*jar == partition
|
||||
&& (!entry.secure || url.scheme == "https")
|
||||
&& (host == *domain
|
||||
|| (!entry.host_only && host.ends_with(&format!(".{domain}"))))
|
||||
&& (url.path == *path
|
||||
|| (url.path.starts_with(path)
|
||||
&& (path.ends_with(b"/") || url.path.get(path.len()) == Some(&b'/'))))
|
||||
})
|
||||
.collect();
|
||||
selected.sort_by(|((_, _, left, _), a), ((_, _, right, _), b)| {
|
||||
right.cmp(left).then(a.sequence.cmp(&b.sequence))
|
||||
});
|
||||
selected
|
||||
.iter()
|
||||
.map(|((_, _, _, name), entry)| format!("{name}={}", entry.value))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_host(url: &UrlParts) -> String {
|
||||
let host = url.hostname.strip_suffix('.').unwrap_or(&url.hostname);
|
||||
::url::Host::parse(host).map_or_else(|_| host.to_ascii_lowercase(), |host| host.to_string())
|
||||
}
|
||||
|
||||
// Go uses cookiejar.New(nil): buckets use the final two labels, with no PSL.
|
||||
fn jar_key(host: &str) -> String {
|
||||
if host.parse::<IpAddr>().is_ok() || host.contains(':') {
|
||||
return host.to_owned();
|
||||
}
|
||||
let mut dots = host.rmatch_indices('.');
|
||||
dots.next();
|
||||
host[dots.next().map_or(0, |(index, _)| index + 1)..].to_owned()
|
||||
}
|
||||
|
||||
fn default_path(path: &[u8]) -> Vec<u8> {
|
||||
let end = path.iter().rposition(|byte| *byte == b'/').unwrap_or(0);
|
||||
if !path.starts_with(b"/") || end == 0 {
|
||||
b"/".to_vec()
|
||||
} else {
|
||||
path[..end].to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn paths_domains_expiry_quoting_and_clear_are_session_local() {
|
||||
let mut jar = CookieJar::default();
|
||||
let url = UrlParts::parse("https://api.example.test/a/b").unwrap();
|
||||
let mut headers = http::HeaderMap::new();
|
||||
for value in [
|
||||
"root=1; Path=/",
|
||||
"deep=2",
|
||||
"wide=3; Domain=.example.test; Secure",
|
||||
"quoted=\"a b\"; Path=/",
|
||||
"bad=4; Domain=other.test",
|
||||
"old=5; Max-Age=0",
|
||||
"public=6; Domain=test; Path=/",
|
||||
] {
|
||||
headers.append(http::header::SET_COOKIE, value.parse().unwrap());
|
||||
}
|
||||
jar.store(&url, &headers);
|
||||
assert_eq!(
|
||||
jar.header(&url),
|
||||
"deep=2; wide=3; root=1; quoted=\"a b\"; public=6"
|
||||
);
|
||||
assert_eq!(
|
||||
jar.header(&UrlParts::parse("http://sub.example.test/a/b").unwrap()),
|
||||
"public=6"
|
||||
);
|
||||
assert_eq!(
|
||||
jar.header(&UrlParts::parse("https://other.test/a/b").unwrap()),
|
||||
""
|
||||
);
|
||||
assert_eq!(CookieJar::default().header(&url), "");
|
||||
jar.clear();
|
||||
assert_eq!(jar.header(&url), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use crate::policy::is_private_ip;
|
||||
use bytes::Bytes;
|
||||
use futures_util::future::BoxFuture;
|
||||
use hickory_proto::op::{Message, MessageType, OpCode, Query, ResponseCode};
|
||||
use hickory_proto::rr::{Name, RData, RecordType};
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
|
||||
use hyper_util::client::legacy::Client;
|
||||
use hyper_util::client::legacy::connect::{HttpConnector, dns};
|
||||
use hyper_util::rt::{TokioExecutor, TokioTimer};
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::{Duration, Instant};
|
||||
use tower_service::Service;
|
||||
|
||||
pub type LookupFuture = BoxFuture<'static, io::Result<Vec<IpAddr>>>;
|
||||
|
||||
/// Trusted platform resolver injection. JavaScript cannot supply DNS answers.
|
||||
pub trait Lookup: Send + Sync {
|
||||
fn lookup(&self, hostname: &str) -> LookupFuture;
|
||||
}
|
||||
|
||||
pub struct SystemLookup;
|
||||
|
||||
impl Lookup for SystemLookup {
|
||||
fn lookup(&self, hostname: &str) -> LookupFuture {
|
||||
let hostname = hostname.to_owned();
|
||||
Box::pin(async move {
|
||||
Ok(tokio::net::lookup_host((hostname, 0))
|
||||
.await?
|
||||
.map(|address| address.ip())
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type DohClient = Client<HttpsConnector<HttpConnector>, Full<Bytes>>;
|
||||
|
||||
struct Cached {
|
||||
addresses: Vec<IpAddr>,
|
||||
expires: Instant,
|
||||
}
|
||||
|
||||
pub(crate) struct Dns {
|
||||
lookup: Arc<dyn Lookup>,
|
||||
doh: DohClient,
|
||||
upstreams: Vec<http::Uri>,
|
||||
cache: Mutex<HashMap<String, Cached>>,
|
||||
}
|
||||
|
||||
impl Dns {
|
||||
pub(crate) async fn has_private_address(&self, hostname: &str) -> bool {
|
||||
self.lookup
|
||||
.lookup(hostname)
|
||||
.await
|
||||
.is_ok_and(|addresses| addresses.into_iter().any(is_private_ip))
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
tls: rustls::ClientConfig,
|
||||
lookup: Arc<dyn Lookup>,
|
||||
upstreams: &[String],
|
||||
) -> io::Result<Self> {
|
||||
let upstreams = upstreams
|
||||
.iter()
|
||||
.map(|url| {
|
||||
let uri = url.parse::<http::Uri>().map_err(io::Error::other)?;
|
||||
// Literal HTTPS endpoints avoid recursively resolving the resolver.
|
||||
if uri.scheme_str() != Some("https")
|
||||
|| uri
|
||||
.host()
|
||||
.and_then(|host| host.trim_matches(['[', ']']).parse::<IpAddr>().ok())
|
||||
.is_none()
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"DoH upstream must use HTTPS and an IP literal",
|
||||
));
|
||||
}
|
||||
Ok(uri)
|
||||
})
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
let mut http = HttpConnector::new();
|
||||
http.enforce_http(false);
|
||||
http.set_connect_timeout(Some(Duration::from_secs(5)));
|
||||
let connector = HttpsConnectorBuilder::new()
|
||||
.with_tls_config(tls)
|
||||
.https_only()
|
||||
.enable_http1()
|
||||
.enable_http2()
|
||||
.wrap_connector(http);
|
||||
let doh = Client::builder(TokioExecutor::new())
|
||||
.pool_timer(TokioTimer::new())
|
||||
.pool_idle_timeout(Duration::from_secs(60))
|
||||
.pool_max_idle_per_host(2)
|
||||
.build(connector);
|
||||
Ok(Self {
|
||||
lookup,
|
||||
doh,
|
||||
upstreams,
|
||||
cache: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve(
|
||||
&self,
|
||||
host: &str,
|
||||
allow_private: bool,
|
||||
) -> io::Result<Vec<SocketAddr>> {
|
||||
let addresses = match self.lookup.lookup(host).await {
|
||||
Ok(addresses) => addresses,
|
||||
Err(original) => {
|
||||
if self.upstreams.is_empty() {
|
||||
return Err(original);
|
||||
}
|
||||
self.fallback(host).await?
|
||||
}
|
||||
};
|
||||
let addresses: Vec<_> = addresses
|
||||
.into_iter()
|
||||
.filter(|address| allow_private || !is_private_ip(*address))
|
||||
.map(|address| SocketAddr::new(address, 0))
|
||||
.collect();
|
||||
if addresses.is_empty() {
|
||||
return Err(io::Error::other(format!(
|
||||
"network access denied: no permitted DNS addresses for '{host}'"
|
||||
)));
|
||||
}
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
async fn fallback(&self, host: &str) -> io::Result<Vec<IpAddr>> {
|
||||
let key = host.to_lowercase();
|
||||
if let Some(entry) = self
|
||||
.cache
|
||||
.lock()
|
||||
.expect("DNS cache lock")
|
||||
.get(&key)
|
||||
.filter(|entry| Instant::now() < entry.expires)
|
||||
{
|
||||
return if entry.addresses.is_empty() {
|
||||
Err(io::Error::other("cached DNS lookup failure"))
|
||||
} else {
|
||||
Ok(entry.addresses.clone())
|
||||
};
|
||||
}
|
||||
let mut result = Err(io::Error::other("DNS-over-HTTPS lookup failed"));
|
||||
for upstream in &self.upstreams {
|
||||
result = self.query(upstream, host, RecordType::A).await;
|
||||
if matches!(&result, Ok((addresses, _)) if addresses.is_empty()) {
|
||||
result = self.query(upstream, host, RecordType::AAAA).await;
|
||||
}
|
||||
if matches!(&result, Ok((addresses, _)) if !addresses.is_empty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (addresses, seconds) = match &result {
|
||||
Ok((addresses, ttl)) if !addresses.is_empty() => {
|
||||
(addresses.clone(), u64::from((*ttl).clamp(60, 1800)))
|
||||
}
|
||||
_ => (Vec::new(), 30),
|
||||
};
|
||||
let mut cache = self.cache.lock().expect("DNS cache lock");
|
||||
if cache.len() >= 256 {
|
||||
cache.retain(|_, entry| Instant::now() < entry.expires);
|
||||
if cache.len() >= 256 {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
cache.insert(
|
||||
key,
|
||||
Cached {
|
||||
addresses: addresses.clone(),
|
||||
expires: Instant::now() + Duration::from_secs(seconds),
|
||||
},
|
||||
);
|
||||
if addresses.is_empty() {
|
||||
Err(io::Error::other("DNS-over-HTTPS returned no addresses"))
|
||||
} else {
|
||||
Ok(addresses)
|
||||
}
|
||||
}
|
||||
|
||||
async fn query(
|
||||
&self,
|
||||
upstream: &http::Uri,
|
||||
host: &str,
|
||||
kind: RecordType,
|
||||
) -> io::Result<(Vec<IpAddr>, u32)> {
|
||||
let mut message = Message::new(0, MessageType::Query, OpCode::Query);
|
||||
message.metadata.recursion_desired = true;
|
||||
message.queries.push(Query::query(
|
||||
Name::from_ascii(host).map_err(io::Error::other)?,
|
||||
kind,
|
||||
));
|
||||
let request = http::Request::post(upstream.clone())
|
||||
.header("Content-Type", "application/dns-message")
|
||||
.header("Accept", "application/dns-message")
|
||||
.body(Full::new(Bytes::from(
|
||||
message.to_vec().map_err(io::Error::other)?,
|
||||
)))
|
||||
.map_err(io::Error::other)?;
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let response = self.doh.request(request).await.map_err(io::Error::other)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(io::Error::other("DoH HTTP failure"));
|
||||
}
|
||||
let mut body = http_body_util::Limited::new(response.into_body(), 65536);
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(frame) = body.frame().await {
|
||||
if let Ok(data) = frame.map_err(io::Error::other)?.into_data() {
|
||||
bytes.extend_from_slice(&data);
|
||||
}
|
||||
}
|
||||
let response = Message::from_vec(&bytes).map_err(io::Error::other)?;
|
||||
if response.metadata.response_code != ResponseCode::NoError
|
||||
|| response.metadata.message_type != MessageType::Response
|
||||
{
|
||||
return Err(io::Error::other("DoH DNS failure"));
|
||||
}
|
||||
let mut addresses = Vec::new();
|
||||
let mut ttl = u32::MAX;
|
||||
for record in response.answers {
|
||||
let address = match record.data {
|
||||
RData::A(address) => IpAddr::V4(address.0),
|
||||
RData::AAAA(address) => IpAddr::V6(address.0),
|
||||
_ => continue,
|
||||
};
|
||||
addresses.push(address);
|
||||
ttl = ttl.min(record.ttl);
|
||||
}
|
||||
Ok((addresses, ttl))
|
||||
})
|
||||
.await
|
||||
.map_err(io::Error::other)?
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Resolver {
|
||||
pub dns: Arc<Dns>,
|
||||
pub allow_private: bool,
|
||||
}
|
||||
|
||||
impl Service<dns::Name> for Resolver {
|
||||
type Response = std::vec::IntoIter<SocketAddr>;
|
||||
type Error = io::Error;
|
||||
type Future = BoxFuture<'static, io::Result<Self::Response>>;
|
||||
|
||||
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, name: dns::Name) -> Self::Future {
|
||||
let resolver = self.clone();
|
||||
Box::pin(async move {
|
||||
Ok(resolver
|
||||
.dns
|
||||
.resolve(name.as_str(), resolver.allow_private)
|
||||
.await?
|
||||
.into_iter())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Shared, cancellable HTTP transport for the embedded backend.
|
||||
|
||||
mod client;
|
||||
mod cookies;
|
||||
mod dns;
|
||||
pub mod policy;
|
||||
pub mod query;
|
||||
mod tls;
|
||||
pub mod url;
|
||||
|
||||
pub use client::{
|
||||
HttpRequest, HttpResponse, HttpStream, MAX_RESPONSE_BYTES, NetworkOptions, NetworkService,
|
||||
NetworkSession,
|
||||
};
|
||||
pub use dns::{Lookup, LookupFuture, SystemLookup};
|
||||
|
||||
/// Shared browser identity for native clients and `utils.randomUserAgent()`.
|
||||
pub fn random_user_agent() -> String {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::BuildHasher;
|
||||
|
||||
// Keep the existing 26-major-version window, now ending at Chrome 152.
|
||||
let random = RandomState::new().hash_one(());
|
||||
format!(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{}.0.{}.{} Safari/537.36",
|
||||
127 + random % 26,
|
||||
6000 + (random >> 8) % 1500,
|
||||
100 + (random >> 24) % 200
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Permission checks happen before dispatch and again on every resolved address.
|
||||
|
||||
use crate::url::UrlParts;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NetworkPermissions {
|
||||
pub domains: Vec<String>,
|
||||
pub allow_http: bool,
|
||||
}
|
||||
|
||||
impl NetworkPermissions {
|
||||
pub fn allows_domain(&self, domain: &str) -> bool {
|
||||
let domain = domain.trim().to_lowercase();
|
||||
self.domains.iter().any(|allowed| {
|
||||
let allowed = allowed.trim().to_lowercase();
|
||||
allowed == domain
|
||||
|| allowed.strip_prefix("*.").is_some_and(|suffix| {
|
||||
domain.len() > suffix.len() + 1 && domain.ends_with(&format!(".{suffix}"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(
|
||||
&self,
|
||||
input: &str,
|
||||
allow_private: bool,
|
||||
redirect: bool,
|
||||
) -> Result<UrlParts, String> {
|
||||
let url = UrlParts::parse(input).ok_or_else(|| "invalid URL".to_owned())?;
|
||||
if url.scheme.is_empty() && !redirect {
|
||||
return Err("invalid URL: scheme is required".to_owned());
|
||||
}
|
||||
if url.scheme != "https" && !(self.allow_http && url.scheme == "http") {
|
||||
return Err(if redirect {
|
||||
"redirect blocked: only https is allowed"
|
||||
} else {
|
||||
"network access denied: only https is allowed"
|
||||
}
|
||||
.to_owned());
|
||||
}
|
||||
if url.has_credentials {
|
||||
return Err("invalid URL: embedded credentials are not allowed".to_owned());
|
||||
}
|
||||
if url.hostname.is_empty() {
|
||||
return Err(if redirect {
|
||||
"redirect blocked: hostname is required"
|
||||
} else {
|
||||
"invalid URL: hostname is required"
|
||||
}
|
||||
.to_owned());
|
||||
}
|
||||
if !redirect && !allow_private && private_literal_or_local(&url.hostname) {
|
||||
return Err(format!(
|
||||
"network access denied: private/local network '{}' not allowed",
|
||||
url.hostname
|
||||
));
|
||||
}
|
||||
if !self.allows_domain(&url.hostname) {
|
||||
return Err(format!(
|
||||
"{}: domain '{}' not in allowed list",
|
||||
if redirect {
|
||||
"redirect blocked"
|
||||
} else {
|
||||
"network access denied"
|
||||
},
|
||||
url.hostname
|
||||
));
|
||||
}
|
||||
if redirect && !allow_private && private_literal_or_local(&url.hostname) {
|
||||
return Err("redirect blocked: private/local network access denied".to_owned());
|
||||
}
|
||||
// Hyper skips the resolver for literal addresses. Check the actual URI
|
||||
// too, including any host canonicalization performed by IDNA parsing.
|
||||
let uri = url.request_uri()?;
|
||||
if !allow_private && uri.host().is_some_and(private_literal_or_local) {
|
||||
return Err(format!(
|
||||
"network access denied: private/local network '{}' not allowed",
|
||||
url.hostname
|
||||
));
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn private_literal_or_local(host: &str) -> bool {
|
||||
let host = host
|
||||
.trim()
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.to_lowercase();
|
||||
host == "localhost"
|
||||
|| host.ends_with(".local")
|
||||
|| host
|
||||
.split('%')
|
||||
.next()
|
||||
.and_then(|address| address.parse().ok())
|
||||
.is_some_and(is_private_ip)
|
||||
}
|
||||
|
||||
/// Matches Go net.IP's private/global-unicast checks, including mapped IPv4.
|
||||
/// TEST-NET, CGNAT and reserved unicast ranges are not silently reclassified.
|
||||
pub fn is_private_ip(address: IpAddr) -> bool {
|
||||
match address {
|
||||
IpAddr::V4(ip) => {
|
||||
ip.is_loopback()
|
||||
|| ip.is_private()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_broadcast()
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
if let Some(ip) = ip.to_ipv4_mapped() {
|
||||
return is_private_ip(IpAddr::V4(ip));
|
||||
}
|
||||
ip.is_loopback()
|
||||
|| ip.is_unique_local()
|
||||
|| ip.is_unicast_link_local()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_unspecified()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn private_ranges_match_go_and_mapped_addresses() {
|
||||
for address in [
|
||||
"127.0.0.1",
|
||||
"10.2.3.4",
|
||||
"169.254.1.1",
|
||||
"172.31.0.1",
|
||||
"192.168.1.1",
|
||||
"224.0.0.1",
|
||||
"255.255.255.255",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
"::1",
|
||||
"fc00::1",
|
||||
"fe80::1",
|
||||
"ff02::1",
|
||||
"::ffff:127.0.0.1",
|
||||
] {
|
||||
assert!(is_private_ip(address.parse().unwrap()), "{address}");
|
||||
}
|
||||
for address in [
|
||||
"1.1.1.1",
|
||||
"100.64.0.1",
|
||||
"192.0.2.1",
|
||||
"198.51.100.1",
|
||||
"203.0.113.1",
|
||||
"240.0.0.1",
|
||||
"0.0.0.1",
|
||||
"2001:db8::1",
|
||||
"::127.0.0.1",
|
||||
"::ffff:192.0.2.1",
|
||||
] {
|
||||
assert!(!is_private_ip(address.parse().unwrap()), "{address}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_by_default_and_validate_redirects_and_actual_literal() {
|
||||
let mut permissions = NetworkPermissions::default();
|
||||
assert!(
|
||||
permissions
|
||||
.validate("https://api.example.test/x", false, false)
|
||||
.is_err()
|
||||
);
|
||||
permissions.domains = vec!["*.example.test".into(), "127.1".into(), "localhost".into()];
|
||||
assert!(
|
||||
permissions
|
||||
.validate("https://api.example.test/x/../y", false, false)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(!permissions.allows_domain("example.test"));
|
||||
assert!(!permissions.allows_domain("badexample.test"));
|
||||
assert!(
|
||||
permissions
|
||||
.validate("https://127.1/x", false, false)
|
||||
.unwrap_err()
|
||||
.contains("private/local")
|
||||
);
|
||||
assert!(
|
||||
permissions
|
||||
.validate("https://user@api.example.test", false, false)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
permissions
|
||||
.validate("http://api.example.test", false, true)
|
||||
.unwrap_err(),
|
||||
"redirect blocked: only https is allowed"
|
||||
);
|
||||
assert!(
|
||||
permissions
|
||||
.validate("https://localhost", true, false)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Go net/url query encoding, including invalid-pair and semicolon handling.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub type Query = BTreeMap<Vec<u8>, Vec<Vec<u8>>>;
|
||||
|
||||
pub fn parse(query: &str) -> Query {
|
||||
let mut values = Query::new();
|
||||
for pair in query
|
||||
.split('&')
|
||||
.filter(|pair| !pair.is_empty() && !pair.contains(';'))
|
||||
{
|
||||
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
if let (Some(key), Some(value)) = (decode(key), decode(value)) {
|
||||
values.entry(key).or_default().push(value);
|
||||
}
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
pub fn set(values: &mut Query, key: &str, value: &str) {
|
||||
values.insert(key.as_bytes().to_vec(), vec![value.as_bytes().to_vec()]);
|
||||
}
|
||||
|
||||
pub fn encode(values: &Query) -> String {
|
||||
values
|
||||
.iter()
|
||||
.flat_map(|(key, values)| {
|
||||
values
|
||||
.iter()
|
||||
.map(move |value| format!("{}={}", escape(key), escape(value)))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn escape(value: &[u8]) -> String {
|
||||
let mut result = String::new();
|
||||
for byte in value {
|
||||
if byte.is_ascii_alphanumeric() || b"-_.~".contains(byte) {
|
||||
result.push(char::from(*byte));
|
||||
} else if *byte == b' ' {
|
||||
result.push('+');
|
||||
} else {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(result, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn decode(value: &str) -> Option<Vec<u8>> {
|
||||
let mut result = Vec::new();
|
||||
let mut bytes = value.bytes();
|
||||
while let Some(byte) = bytes.next() {
|
||||
result.push(match byte {
|
||||
b'+' => b' ',
|
||||
b'%' => {
|
||||
(char::from(bytes.next()?).to_digit(16)? * 16
|
||||
+ char::from(bytes.next()?).to_digit(16)?) as u8
|
||||
}
|
||||
_ => byte,
|
||||
});
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn query_keeps_bytes_duplicates_and_go_escaping() {
|
||||
assert_eq!(
|
||||
super::encode(&super::parse(
|
||||
"z=one+two&x=%FF&x=%2f&bad=%Q0&semi=a;b&empty&~!=*"
|
||||
)),
|
||||
"empty=&x=%FF&x=%2F&z=one+two&~%21=%2A"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
|
||||
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
|
||||
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
|
||||
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
|
||||
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
|
||||
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
|
||||
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
|
||||
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
|
||||
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
|
||||
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
|
||||
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
|
||||
/q4AaOeMSQ+2b1tbFfLn
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,73 @@
|
||||
use rustls::ClientConfig;
|
||||
use rustls::pki_types::CertificateDer;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn configuration(extra_roots: &[CertificateDer<'static>]) -> io::Result<ClientConfig> {
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let builder = ClientConfig::builder_with_provider(Arc::clone(&provider))
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(io::Error::other)?;
|
||||
let supplemental = rustls_pemfile::certs(&mut include_bytes!("roots.pem").as_slice())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
#[cfg(target_vendor = "apple")]
|
||||
let builder = {
|
||||
// This is the OS certificate and hostname verifier, including iOS's
|
||||
// system trust store. It never skips certificate verification.
|
||||
let mut supplemental = supplemental;
|
||||
supplemental.extend_from_slice(extra_roots);
|
||||
let verifier =
|
||||
rustls_platform_verifier::Verifier::new_with_extra_roots(supplemental, provider)
|
||||
.map_err(io::Error::other)?;
|
||||
builder
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(verifier))
|
||||
};
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
let builder = {
|
||||
let load = || {
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add_parsable_certificates(rustls_native_certs::load_native_certs().certs);
|
||||
#[cfg(target_os = "android")]
|
||||
for directory in [
|
||||
"/system/etc/security/cacerts",
|
||||
"/data/misc/keychain/certs-added",
|
||||
"/apex/com.android.conscrypt/cacerts",
|
||||
] {
|
||||
if let Ok(entries) = std::fs::read_dir(directory) {
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(pem) = std::fs::read(entry.path()) {
|
||||
roots.add_parsable_certificates(
|
||||
rustls_pemfile::certs(&mut pem.as_slice()).flatten(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
roots.add_parsable_certificates(supplemental);
|
||||
Arc::new(roots)
|
||||
};
|
||||
// Match Go's process-wide system CA snapshot on Android. Only immutable
|
||||
// trust anchors are shared; caller-supplied roots never enter this cache.
|
||||
#[cfg(target_os = "android")]
|
||||
let roots = {
|
||||
static ROOTS: std::sync::OnceLock<Arc<rustls::RootCertStore>> =
|
||||
std::sync::OnceLock::new();
|
||||
Arc::clone(ROOTS.get_or_init(load))
|
||||
};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let roots = load();
|
||||
let roots = if extra_roots.is_empty() {
|
||||
roots
|
||||
} else {
|
||||
let mut scoped = (*roots).clone();
|
||||
scoped.add_parsable_certificates(extra_roots.iter().cloned());
|
||||
Arc::new(scoped)
|
||||
};
|
||||
builder.with_root_certificates(roots)
|
||||
};
|
||||
let mut config = builder.with_no_client_auth();
|
||||
config.resumption = rustls::client::Resumption::in_memory_sessions(64);
|
||||
Ok(config)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! 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)]
|
||||
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,
|
||||
}
|
||||
|
||||
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;
|
||||
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,
|
||||
});
|
||||
}
|
||||
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)?;
|
||||
host
|
||||
} else {
|
||||
authority
|
||||
};
|
||||
hostname = parse_host(host, scheme)?;
|
||||
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 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,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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/?"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user