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:
zarzet committed 2026-09-14 22:58:27 +07:00
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
+238
View File
@@ -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), "");
}
}
+266
View File
@@ -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())
})
}
}
+30
View File
@@ -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
)
}
+205
View File
@@ -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()
);
}
}
+79
View File
@@ -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"
);
}
}
+45
View File
@@ -0,0 +1,45 @@
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----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-----
+73
View File
@@ -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)
}
+410
View File
@@ -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/?"
);
}
}