use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use std::fmt;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::error::{ConnectorError, Result};
pub(crate) type ProxyStream = Box<dyn ProxyIo + Unpin + Send>;
pub(crate) trait ProxyIo: AsyncRead + AsyncWrite {}
impl<T: AsyncRead + AsyncWrite> ProxyIo for T {}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ProxyScheme {
Http,
Https,
}
const MAX_CONNECT_HEADER: usize = 8 * 1024;
const PROXY_FAIL_LOG_EVERY: u64 = 20;
static PROXY_FAIL_STREAK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static PROXY_LAST_REASON: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static PROXY_CLEARTEXT_AUTH_WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[derive(Clone)]
pub struct ProxyAuth {
username: String,
password: String,
}
impl ProxyAuth {
pub fn basic_header_value(&self) -> String {
let raw = format!("{}:{}", self.username, self.password);
format!("Basic {}", BASE64.encode(raw.as_bytes()))
}
}
impl fmt::Debug for ProxyAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:***", self.username)
}
}
impl fmt::Display for ProxyAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:***", self.username)
}
}
#[derive(Clone)]
pub struct ProxyConfig {
pub scheme: ProxyScheme,
pub host: String,
pub port: u16,
pub auth: Option<ProxyAuth>,
}
impl ProxyConfig {
pub fn parse_url(raw: &str) -> Result<ProxyConfig> {
let (scheme, rest) = if let Some(rest) = raw.strip_prefix("https://") {
(ProxyScheme::Https, rest)
} else if let Some(rest) = raw.strip_prefix("http://") {
(ProxyScheme::Http, rest)
} else {
let scheme = raw.split("://").next().unwrap_or("(none)");
return Err(ConnectorError::InvalidConfig(format!(
"unsupported proxy URL scheme '{scheme}' (expected http:// or https://)"
)));
};
let (auth, hostport) = match rest.rsplit_once('@') {
Some((userinfo, hp)) => {
let (u, p) = userinfo.split_once(':').unwrap_or((userinfo, ""));
(
Some(ProxyAuth {
username: u.to_string(),
password: p.to_string(),
}),
hp,
)
}
None => (None, rest),
};
let hostport = hostport.split('/').next().unwrap_or(hostport);
if hostport.matches(':').count() == 1
&& let Some((_, p)) = hostport.rsplit_once(':')
&& !p.is_empty()
&& !p.chars().all(|c| c.is_ascii_digit())
{
return Err(ConnectorError::InvalidConfig(format!(
"invalid proxy port: {p}"
)));
}
let (host, port) = split_host_port(hostport);
let port = port.unwrap_or(3128);
if host.is_empty() {
return Err(ConnectorError::InvalidConfig(
"proxy URL missing host".to_string(),
));
}
Ok(ProxyConfig {
scheme,
host,
port,
auth,
})
}
}
impl fmt::Debug for ProxyConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ProxyConfig {{ {}:{} }}", self.host, self.port)
}
}
impl fmt::Display for ProxyConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.host, self.port)
}
}
pub(crate) fn split_host_port(authority: &str) -> (String, Option<u16>) {
if let Some(rest) = authority.strip_prefix('[') {
if let Some((addr, tail)) = rest.split_once(']') {
let port = tail.strip_prefix(':').and_then(|p| p.parse::<u16>().ok());
return (addr.to_string(), port);
}
return (authority.to_string(), None);
}
if authority.matches(':').count() > 1 {
return (authority.to_string(), None);
}
match authority.rsplit_once(':') {
Some((h, p)) if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) => {
(h.to_string(), p.parse::<u16>().ok())
}
_ => (authority.to_string(), None),
}
}
fn strip_port(host: &str) -> String {
split_host_port(host).0
}
pub(crate) fn host_matches_no_proxy(target_host: &str, no_proxy: &str) -> bool {
let target = strip_port(target_host).to_ascii_lowercase();
for raw in no_proxy.split(',') {
let entry = raw.trim().trim_end_matches('.').to_ascii_lowercase();
if entry.is_empty() {
continue;
}
if entry == "*" {
return true;
}
let entry = entry.strip_prefix("*.").unwrap_or(&entry);
let entry = entry.trim_start_matches('.');
if target == entry || target.ends_with(&format!(".{entry}")) {
return true;
}
}
false
}
pub(crate) fn resolve_proxy(
target_host: &str,
use_tls: bool,
explicit: Option<&str>,
env: &dyn Fn(&str) -> Option<String>,
) -> Result<Option<ProxyConfig>> {
let nonblank = |s: String| -> Option<String> {
let t = s.trim();
(!t.is_empty()).then(|| t.to_string())
};
let no_proxy = env("NO_PROXY")
.and_then(&nonblank)
.or_else(|| env("no_proxy").and_then(&nonblank));
if let Some(np) = no_proxy.as_deref()
&& host_matches_no_proxy(target_host, np)
{
return Ok(None);
}
let pick =
|keys: &[&str]| -> Option<String> { keys.iter().find_map(|k| env(k).and_then(&nonblank)) };
if let Some(e) = explicit.and_then(|s| nonblank(s.to_string())) {
return Ok(Some(ProxyConfig::parse_url(&e)?));
}
let raw = if use_tls {
pick(&["HTTPS_PROXY", "https_proxy"])
.or_else(|| pick(&["HTTP_PROXY", "http_proxy"]))
.or_else(|| pick(&["ALL_PROXY", "all_proxy"]))
} else {
pick(&["HTTP_PROXY", "http_proxy"]).or_else(|| pick(&["ALL_PROXY", "all_proxy"]))
};
match raw {
Some(url) => match ProxyConfig::parse_url(&url) {
Ok(cfg) => Ok(Some(cfg)),
Err(e) => {
tracing::warn!(
error = %e,
"ignoring unusable proxy from environment; connecting directly"
);
Ok(None)
}
},
None => Ok(None),
}
}
pub(crate) fn build_http_client(target_url: &str) -> reqwest::Client {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let host = split_host_port(host_authority(target_url)).0;
let proxy = resolve_proxy(
&host,
true,
std::env::var("STUDIO_PROXY").ok().as_deref(),
&|k| std::env::var(k).ok(),
)
.ok()
.flatten();
let mut builder = reqwest::Client::builder();
if let Some(cfg) = &proxy {
let scheme = match cfg.scheme {
ProxyScheme::Http => "http",
ProxyScheme::Https => "https",
};
let url = format!("{scheme}://{}:{}", cfg.host, cfg.port);
match reqwest::Proxy::all(&url) {
Ok(mut p) => {
if let Some(auth) = &cfg.auth {
p = p.basic_auth(&auth.username, &auth.password);
}
p = p.no_proxy(reqwest::NoProxy::from_env());
builder = builder.proxy(p);
tracing::debug!(proxy = %cfg, target = %host, "HTTP client routing through proxy");
}
Err(e) => {
tracing::warn!(error = %e, "could not build reqwest proxy; connecting directly")
}
}
}
if tls_insecure() {
builder = builder.danger_accept_invalid_certs(true);
} else if let Ok(ca_path) = std::env::var("MATRIX_TLS_CA_CERT") {
match load_ca_certs(&ca_path) {
Ok(certs) => {
for cert in certs {
builder = builder.add_root_certificate(cert);
}
}
Err(e) => {
tracing::warn!(error = %e, "could not load MATRIX_TLS_CA_CERT for HTTP client")
}
}
}
builder.build().unwrap_or_else(|_| reqwest::Client::new())
}
fn tls_insecure() -> bool {
std::env::var("MATRIX_TLS_INSECURE")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
}
fn load_ca_certs(path: &str) -> Result<Vec<reqwest::Certificate>> {
let pem = std::fs::read(path).map_err(|e| {
ConnectorError::ConnectionError(format!("failed to read CA cert {path}: {e}"))
})?;
reqwest::Certificate::from_pem_bundle(&pem)
.map_err(|e| ConnectorError::ConnectionError(format!("invalid CA cert {path}: {e}")))
}
fn host_authority(url: &str) -> &str {
let after_scheme = url.split("://").nth(1).unwrap_or(url);
let after_userinfo = after_scheme
.rsplit_once('@')
.map_or(after_scheme, |(_, h)| h);
after_userinfo
.split(['/', '?', '#'])
.next()
.unwrap_or(after_userinfo)
}
#[derive(Debug)]
enum ConnectStatus {
Established,
AuthRequired,
Failed(String),
Incomplete,
}
fn find_header_end(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4)
}
fn parse_connect_status(buf: &[u8]) -> ConnectStatus {
if find_header_end(buf).is_none() {
return ConnectStatus::Incomplete;
}
let line_end = buf
.windows(2)
.position(|w| w == b"\r\n")
.unwrap_or(buf.len());
let line = String::from_utf8_lossy(&buf[..line_end]);
let mut parts = line.split_whitespace();
let _http = parts.next();
let code = parts.next().unwrap_or("");
match code {
c if c.starts_with('2') => ConnectStatus::Established,
"407" => ConnectStatus::AuthRequired,
other => ConnectStatus::Failed(format!("proxy returned status {other}")),
}
}
pub(crate) async fn connect_through_proxy(
cfg: &ProxyConfig,
target_host: &str,
target_port: u16,
connect_timeout_ms: u64,
) -> Result<ProxyStream> {
metrics::counter!("sdk.proxy.connect_attempts_total").increment(1);
let started = std::time::Instant::now();
let result = match tokio::time::timeout(
std::time::Duration::from_millis(connect_timeout_ms),
connect_through_proxy_inner(cfg, target_host, target_port),
)
.await
{
Ok(r) => r,
Err(_) => Err(ConnectorError::ProxyConnectFailed(format!(
"proxy {cfg} CONNECT handshake timed out after {connect_timeout_ms}ms"
))),
};
match &result {
Ok(_) => {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
metrics::counter!("sdk.proxy.connect_success_total").increment(1);
metrics::histogram!("sdk.proxy.connect_latency_ms").record(elapsed_ms);
record_proxy_recovery(cfg, target_host, target_port, elapsed_ms);
}
Err(e) => {
let reason = proxy_failure_reason(e);
metrics::counter!("sdk.proxy.connect_failures_total", "reason" => reason).increment(1);
record_proxy_failure(cfg, reason, e);
}
}
result
}
fn proxy_failure_reason(err: &ConnectorError) -> &'static str {
match err {
ConnectorError::ProxyUnreachable(_) => "unreachable",
ConnectorError::ProxyAuthFailed(_) => "auth",
ConnectorError::ProxyConnectFailed(_) => "connect_failed",
_ => "other",
}
}
fn record_proxy_failure(cfg: &ProxyConfig, reason: &'static str, err: &ConnectorError) {
use std::sync::atomic::Ordering;
let prev = PROXY_LAST_REASON.swap(reason_code(reason), Ordering::Relaxed);
let streak = PROXY_FAIL_STREAK.fetch_add(1, Ordering::Relaxed) + 1;
let reason_changed = prev != reason_code(reason);
if streak == 1 || reason_changed {
match reason {
"auth" => tracing::error!(
proxy = %cfg, %reason, code = err.code(),
"studio proxy authentication failed — check proxy credentials; \
retrying indefinitely with backoff"
),
_ => tracing::warn!(
proxy = %cfg, %reason, code = err.code(), error = %err,
"studio proxy connect failed — retrying indefinitely with backoff"
),
}
} else if streak.is_multiple_of(PROXY_FAIL_LOG_EVERY) {
tracing::debug!(
proxy = %cfg, %reason, consecutive_failures = streak,
"studio proxy still failing"
);
}
}
fn record_proxy_recovery(cfg: &ProxyConfig, target: &str, port: u16, elapsed_ms: f64) {
use std::sync::atomic::Ordering;
let prior_failures = PROXY_FAIL_STREAK.swap(0, Ordering::Relaxed);
PROXY_LAST_REASON.store(0, Ordering::Relaxed);
if prior_failures > 0 {
tracing::info!(
proxy = %cfg, target = %format!("{target}:{port}"),
latency_ms = elapsed_ms, recovered_after = prior_failures,
"studio proxy tunnel recovered"
);
} else {
tracing::debug!(
proxy = %cfg, target = %format!("{target}:{port}"),
latency_ms = elapsed_ms, "studio proxy tunnel established"
);
}
}
fn reason_code(reason: &str) -> u64 {
match reason {
"unreachable" => 1,
"auth" => 2,
"connect_failed" => 3,
_ => 4,
}
}
async fn connect_through_proxy_inner(
cfg: &ProxyConfig,
target_host: &str,
target_port: u16,
) -> Result<ProxyStream> {
let proxy_addr = format!("{}:{}", cfg.host, cfg.port);
let tcp = TcpStream::connect(&proxy_addr)
.await
.map_err(|e| ConnectorError::ProxyUnreachable(format!("connect to proxy {cfg}: {e}")))?;
let mut stream: ProxyStream = match cfg.scheme {
ProxyScheme::Http => Box::new(tcp),
ProxyScheme::Https => Box::new(tls_wrap_proxy_hop(tcp, &cfg.host).await?),
};
let mut req = format!(
"CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n"
);
if let Some(auth) = &cfg.auth {
if cfg.scheme == ProxyScheme::Http
&& !PROXY_CLEARTEXT_AUTH_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed)
{
tracing::warn!(
proxy = %cfg,
"sending proxy Basic credentials over a plaintext (http) proxy hop; \
they are recoverable on the Construct-to-proxy network segment. \
Use an https:// proxy URL to encrypt this hop."
);
}
req.push_str(&format!(
"Proxy-Authorization: {}\r\n",
auth.basic_header_value()
));
}
req.push_str("\r\n");
stream
.write_all(req.as_bytes())
.await
.map_err(|e| ConnectorError::ProxyConnectFailed(format!("write CONNECT: {e}")))?;
let mut buf = Vec::with_capacity(256);
let mut one = [0u8; 1];
loop {
let n = stream
.read(&mut one)
.await
.map_err(|e| ConnectorError::ProxyConnectFailed(format!("read CONNECT reply: {e}")))?;
if n == 0 {
return Err(ConnectorError::ProxyConnectFailed(
"proxy closed connection during CONNECT".to_string(),
));
}
buf.push(one[0]);
if find_header_end(&buf).is_some() {
break;
}
if buf.len() > MAX_CONNECT_HEADER {
return Err(ConnectorError::ProxyConnectFailed(
"CONNECT response header exceeded 8 KiB".to_string(),
));
}
}
match parse_connect_status(&buf) {
ConnectStatus::Established => Ok(stream),
ConnectStatus::AuthRequired => Err(ConnectorError::ProxyAuthFailed(format!(
"proxy {cfg} returned 407 (check proxy credentials)"
))),
ConnectStatus::Failed(msg) => Err(ConnectorError::ProxyConnectFailed(msg)),
ConnectStatus::Incomplete => Err(ConnectorError::ProxyConnectFailed(
"incomplete CONNECT response".to_string(),
)),
}
}
async fn tls_wrap_proxy_hop(
tcp: TcpStream,
proxy_host: &str,
) -> Result<tokio_rustls::client::TlsStream<TcpStream>> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let config = build_proxy_hop_rustls()?;
let server_name = rustls::pki_types::ServerName::try_from(proxy_host.to_string())
.map_err(|e| ConnectorError::ProxyConnectFailed(format!("invalid proxy DNS name: {e}")))?;
tokio_rustls::TlsConnector::from(Arc::new(config))
.connect(server_name, tcp)
.await
.map_err(|e| ConnectorError::ProxyConnectFailed(format!("proxy TLS handshake failed: {e}")))
}
fn build_proxy_hop_rustls() -> Result<rustls::ClientConfig> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let builder = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| ConnectorError::ProxyConnectFailed(format!("TLS config error: {e}")))?;
let insecure = std::env::var("MATRIX_TLS_INSECURE")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
if insecure {
tracing::warn!(
"proxy TLS verification DISABLED (MATRIX_TLS_INSECURE=true). Do NOT use in production!"
);
return Ok(builder
.dangerous()
.with_custom_certificate_verifier(Arc::new(super::grpc::InsecureServerCertVerifier))
.with_no_client_auth());
}
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
if let Ok(ca_path) = std::env::var("MATRIX_TLS_CA_CERT") {
let ca_pem = std::fs::read(&ca_path).map_err(|e| {
ConnectorError::ProxyConnectFailed(format!("read proxy CA cert {ca_path}: {e}"))
})?;
let mut reader = std::io::BufReader::new(&ca_pem[..]);
for cert in rustls_pemfile::certs(&mut reader) {
let cert = cert
.map_err(|e| ConnectorError::ProxyConnectFailed(format!("invalid CA cert: {e}")))?;
roots
.add(cert)
.map_err(|e| ConnectorError::ProxyConnectFailed(format!("add CA cert: {e}")))?;
}
}
Ok(builder.with_root_certificates(roots).with_no_client_auth())
}
#[cfg(test)]
mod doc_verification {
use super::*;
#[tokio::test]
async fn real_proxy_tunnel_with_basic_auth() {
let (Ok(pport), Ok(bport)) = (
std::env::var("REAL_PROXY_PORT"),
std::env::var("REAL_BACKEND_PORT"),
) else {
eprintln!("skipping real_proxy_tunnel: REAL_PROXY_PORT/REAL_BACKEND_PORT unset");
return;
};
let pport: u16 = pport.parse().unwrap();
let bport: u16 = bport.parse().unwrap();
let cfg =
ProxyConfig::parse_url(&format!("http://alice:secret@127.0.0.1:{pport}")).unwrap();
let mut s = connect_through_proxy(&cfg, "127.0.0.1", bport, 5000)
.await
.expect("tunnel with good creds");
s.write_all(b"hi").await.unwrap();
let mut buf = vec![0u8; 16];
let n = s.read(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"ECHO:hi", "tunnel did not bridge to backend");
let bad = ProxyConfig::parse_url(&format!("http://alice:wrong@127.0.0.1:{pport}")).unwrap();
let err = connect_through_proxy(&bad, "127.0.0.1", bport, 5000)
.await
.err()
.expect("expected proxy connect to fail");
assert_eq!(err.code(), "PROXY_AUTH_FAILED");
}
#[test]
fn studio_proxy_is_highest_precedence() {
let env = |k: &str| match k {
"HTTPS_PROXY" => Some("http://env-proxy:8080".to_string()),
_ => None,
};
let p = resolve_proxy(
"studio.corp.com",
true,
Some("http://studio-proxy:3128"),
&env,
)
.unwrap()
.unwrap();
assert_eq!(p.host, "studio-proxy");
assert_eq!(p.port, 3128);
}
#[test]
fn https_proxy_preferred_for_tls_and_lowercase_honored() {
let env = |k: &str| match k {
"https_proxy" => Some("http://lower-secure:8080".to_string()),
"HTTP_PROXY" => Some("http://plain:3128".to_string()),
_ => None,
};
let p = resolve_proxy("studio.corp.com", true, None, &env)
.unwrap()
.unwrap();
assert_eq!(p.host, "lower-secure");
}
#[test]
fn no_proxy_forms_from_docs() {
assert!(host_matches_no_proxy("studio.corp.com", "studio.corp.com")); assert!(host_matches_no_proxy("studio.corp.com", ".corp.com")); assert!(host_matches_no_proxy("studio.corp.com", "corp.com")); assert!(host_matches_no_proxy("anything.example", "*")); assert!(host_matches_no_proxy("studio.corp.com:443", ".corp.com")); }
#[test]
fn no_proxy_match_yields_direct_connection() {
let env = |k: &str| match k {
"HTTPS_PROXY" => Some("http://env-proxy:8080".to_string()),
"NO_PROXY" => Some(".corp.com".to_string()),
_ => None,
};
let r = resolve_proxy(
"studio.corp.com",
true,
Some("http://studio-proxy:3128"),
&env,
)
.unwrap();
assert!(r.is_none(), "NO_PROXY should force a direct connection");
}
#[test]
fn port_defaults_to_3128() {
let p = ProxyConfig::parse_url("http://proxy.corp").unwrap();
assert_eq!(p.port, 3128);
}
#[tokio::test]
async fn no_auth_proxy_tunnel_sends_no_auth_header() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut s, _) = listener.accept().await.unwrap();
let mut buf = Vec::new();
let mut one = [0u8; 1];
loop {
let n = s.read(&mut one).await.unwrap();
if n == 0 {
return;
}
buf.push(one[0]);
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let req = String::from_utf8_lossy(&buf).to_ascii_lowercase();
assert!(
!req.contains("proxy-authorization"),
"unexpected auth header sent to no-auth proxy: {req}"
);
s.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\nOK")
.await
.unwrap();
});
let cfg = ProxyConfig::parse_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
assert!(
cfg.auth.is_none(),
"no-credential URL must parse to auth=None"
);
let mut s = connect_through_proxy(&cfg, "studio.corp.com", 443, 5000)
.await
.expect("tunnel through no-auth proxy");
let mut buf = [0u8; 8];
let n = s.read(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"OK");
}
#[test]
fn basic_auth_from_url() {
let p = ProxyConfig::parse_url("http://user:pass@proxy.corp:3128").unwrap();
let auth = p.auth.expect("auth parsed from URL");
assert_eq!(auth.basic_header_value(), "Basic dXNlcjpwYXNz");
}
#[test]
fn credentials_never_appear_in_any_output() {
let p = ProxyConfig::parse_url("http://user:hunter2@proxy.corp:3128").unwrap();
for s in [format!("{p}"), format!("{p:?}")] {
assert!(!s.contains("hunter2"), "leaked password: {s}");
assert!(!s.contains("user:hunter2"), "leaked userinfo: {s}");
}
let err = ConnectorError::ProxyAuthFailed(format!("proxy {p} returned 407"));
assert!(!err.to_string().contains("hunter2"));
}
#[test]
fn documented_error_codes_exist_and_are_recoverable() {
let cases = [
(
ConnectorError::ProxyUnreachable("x".into()),
"PROXY_UNREACHABLE",
),
(
ConnectorError::ProxyConnectFailed("x".into()),
"PROXY_CONNECT_FAILED",
),
(
ConnectorError::ProxyAuthFailed("x".into()),
"PROXY_AUTH_FAILED",
),
];
for (err, code) in cases {
assert_eq!(err.code(), code);
assert!(err.is_recoverable(), "{code} must be recoverable (retried)");
}
}
#[test]
fn blank_explicit_proxy_is_treated_as_unset() {
let env = |_k: &str| None;
assert!(
resolve_proxy("studio", true, Some(""), &env)
.unwrap()
.is_none()
);
assert!(
resolve_proxy("studio", true, Some(" "), &env)
.unwrap()
.is_none()
);
}
#[test]
fn blank_env_proxy_is_ignored() {
let env = |k: &str| match k {
"HTTPS_PROXY" => Some("".to_string()),
_ => None,
};
assert!(resolve_proxy("studio", true, None, &env).unwrap().is_none());
}
#[test]
fn unsupported_env_scheme_falls_through_to_direct() {
let env = |k: &str| match k {
"ALL_PROXY" => Some("socks5://socks.corp:1080".to_string()),
_ => None,
};
assert!(resolve_proxy("studio", true, None, &env).unwrap().is_none());
}
#[test]
fn explicit_bad_scheme_is_hard_error() {
let env = |_k: &str| None;
assert!(resolve_proxy("studio", true, Some("socks5://x:1080"), &env).is_err());
}
#[test]
fn https_proxy_url_selects_tls() {
let p = ProxyConfig::parse_url("https://proxy.corp:3128").unwrap();
assert_eq!(p.scheme, ProxyScheme::Https);
assert_eq!(p.host, "proxy.corp");
assert_eq!(p.port, 3128);
let h = ProxyConfig::parse_url("http://proxy.corp:3128").unwrap();
assert_eq!(h.scheme, ProxyScheme::Http);
}
#[test]
fn no_proxy_star_dot_form() {
assert!(host_matches_no_proxy("studio.corp.com", "*.corp.com"));
assert!(!host_matches_no_proxy("studio.other.com", "*.corp.com"));
}
#[tokio::test]
async fn stalled_proxy_times_out() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (_s, _) = listener.accept().await.unwrap();
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
});
let cfg = ProxyConfig {
scheme: ProxyScheme::Http,
host: "127.0.0.1".into(),
port: addr.port(),
auth: None,
};
let err = connect_through_proxy(&cfg, "studio.corp.com", 443, 300)
.await
.err()
.expect("expected proxy connect to fail");
assert_eq!(err.code(), "PROXY_CONNECT_FAILED");
assert!(err.to_string().contains("timed out"));
}
#[test]
fn failure_reasons_are_stable_labels() {
assert_eq!(
proxy_failure_reason(&ConnectorError::ProxyUnreachable("x".into())),
"unreachable"
);
assert_eq!(
proxy_failure_reason(&ConnectorError::ProxyAuthFailed("x".into())),
"auth"
);
assert_eq!(
proxy_failure_reason(&ConnectorError::ProxyConnectFailed("x".into())),
"connect_failed"
);
assert_ne!(reason_code("unreachable"), reason_code("auth"));
assert_ne!(reason_code("auth"), reason_code("connect_failed"));
}
#[tokio::test]
async fn proxy_502_classified_as_connect_failed() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut s, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 512];
let _ = s.read(&mut buf).await.unwrap();
s.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
.await
.unwrap();
});
let cfg = ProxyConfig {
scheme: ProxyScheme::Http,
host: "127.0.0.1".into(),
port: addr.port(),
auth: None,
};
let err = connect_through_proxy(&cfg, "studio.corp.com", 443, 5000)
.await
.err()
.expect("expected proxy connect to fail");
assert_eq!(proxy_failure_reason(&err), "connect_failed");
}
#[tokio::test]
async fn proxy_407_surfaces_auth_failed_code() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut s, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 512];
let _ = s.read(&mut buf).await.unwrap();
s.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")
.await
.unwrap();
});
let cfg = ProxyConfig {
scheme: ProxyScheme::Http,
host: "127.0.0.1".into(),
port: addr.port(),
auth: None,
};
let err = connect_through_proxy(&cfg, "studio.corp.com", 443, 5000)
.await
.err()
.expect("expected proxy connect to fail");
assert_eq!(err.code(), "PROXY_AUTH_FAILED");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_url_with_auth() {
let p = ProxyConfig::parse_url("http://alice:secret@proxy.corp:3128").unwrap();
assert_eq!(p.host, "proxy.corp");
assert_eq!(p.port, 3128);
let auth = p.auth.unwrap();
assert_eq!(auth.basic_header_value(), "Basic YWxpY2U6c2VjcmV0");
}
#[test]
fn parses_url_without_auth_defaults_port() {
let p = ProxyConfig::parse_url("http://proxy.corp").unwrap();
assert_eq!(p.host, "proxy.corp");
assert_eq!(p.port, 3128);
assert!(p.auth.is_none());
}
#[test]
fn rejects_bad_scheme() {
assert!(ProxyConfig::parse_url("ftp://proxy.corp:3128").is_err());
}
#[test]
fn redacts_credentials_in_debug_and_display() {
let p = ProxyConfig::parse_url("http://alice:secret@proxy.corp:3128").unwrap();
let dbg = format!("{p:?}");
let disp = format!("{p}");
assert!(!dbg.contains("secret"), "debug leaked password: {dbg}");
assert!(!disp.contains("secret"), "display leaked password: {disp}");
assert!(!dbg.contains("alice:secret"));
}
#[test]
fn no_proxy_exact_and_suffix_and_wildcard() {
assert!(host_matches_no_proxy("studio.corp.com", "studio.corp.com"));
assert!(host_matches_no_proxy("studio.corp.com", ".corp.com"));
assert!(host_matches_no_proxy("studio.corp.com", "corp.com"));
assert!(host_matches_no_proxy("anything", "*"));
assert!(host_matches_no_proxy("localhost", "localhost"));
assert!(!host_matches_no_proxy("studio.corp.com", "other.com"));
assert!(host_matches_no_proxy("studio.corp.com:443", ".corp.com"));
assert!(host_matches_no_proxy(
"studio.corp.com",
"a.com, .corp.com ,b.com"
));
}
#[test]
fn resolve_prefers_explicit_then_env() {
let env = |_k: &str| None;
let p = resolve_proxy("studio.corp.com", true, Some("http://p1:3128"), &env)
.unwrap()
.unwrap();
assert_eq!(p.host, "p1");
}
#[test]
fn resolve_uses_https_proxy_for_tls_target() {
let env = |k: &str| match k {
"HTTPS_PROXY" => Some("http://secure:8080".to_string()),
"HTTP_PROXY" => Some("http://plain:3128".to_string()),
_ => None,
};
let p = resolve_proxy("studio.corp.com", true, None, &env)
.unwrap()
.unwrap();
assert_eq!(p.host, "secure");
}
#[test]
fn resolve_returns_none_when_no_proxy_matches() {
let env = |k: &str| match k {
"HTTPS_PROXY" => Some("http://secure:8080".to_string()),
"NO_PROXY" => Some(".corp.com".to_string()),
_ => None,
};
let r = resolve_proxy("studio.corp.com", true, None, &env).unwrap();
assert!(r.is_none());
}
#[test]
fn resolve_returns_none_when_unset() {
let env = |_k: &str| None;
assert!(
resolve_proxy("studio.corp.com", true, None, &env)
.unwrap()
.is_none()
);
}
#[test]
fn split_host_port_variants() {
assert_eq!(split_host_port("studio:443"), ("studio".into(), Some(443)));
assert_eq!(split_host_port("studio"), ("studio".into(), None));
assert_eq!(split_host_port("[::1]:443"), ("::1".into(), Some(443)));
assert_eq!(
split_host_port("[2001:db8::1]"),
("2001:db8::1".into(), None)
);
assert_eq!(split_host_port("2001:db8::1"), ("2001:db8::1".into(), None));
}
#[test]
fn parses_bracketed_ipv6_proxy_url() {
let p = ProxyConfig::parse_url("http://[::1]:3128").unwrap();
assert_eq!(p.host, "::1");
assert_eq!(p.port, 3128);
}
#[test]
fn rejects_non_numeric_port() {
assert!(ProxyConfig::parse_url("http://proxy:abc").is_err());
}
#[test]
fn parses_200_established() {
let resp = b"HTTP/1.1 200 Connection Established\r\nVia: x\r\n\r\n";
assert!(matches!(
parse_connect_status(resp),
ConnectStatus::Established
));
}
#[test]
fn parses_http10_200() {
let resp = b"HTTP/1.0 200 OK\r\n\r\n";
assert!(matches!(
parse_connect_status(resp),
ConnectStatus::Established
));
}
#[test]
fn parses_407() {
let resp = b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n";
assert!(matches!(
parse_connect_status(resp),
ConnectStatus::AuthRequired
));
}
#[test]
fn parses_502_failed() {
let resp = b"HTTP/1.1 502 Bad Gateway\r\n\r\n";
assert!(matches!(
parse_connect_status(resp),
ConnectStatus::Failed(_)
));
}
#[test]
fn incomplete_without_terminator() {
let resp = b"HTTP/1.1 200 Connection Esta";
assert!(matches!(
parse_connect_status(resp),
ConnectStatus::Incomplete
));
}
#[tokio::test]
async fn tunnel_succeeds_and_preserves_leftover_bytes() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut s, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let n = s.read(&mut buf).await.unwrap();
let req = String::from_utf8_lossy(&buf[..n]);
assert!(
req.starts_with("CONNECT studio.example:443 HTTP/1.1\r\n"),
"got: {req}"
);
assert!(req.contains("Host: studio.example:443\r\n"));
s.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\nLEFTOVER")
.await
.unwrap();
let n = s.read(&mut buf).await.unwrap();
s.write_all(&buf[..n]).await.unwrap();
});
let cfg = ProxyConfig {
scheme: ProxyScheme::Http,
host: "127.0.0.1".into(),
port: addr.port(),
auth: None,
};
let mut stream = connect_through_proxy(&cfg, "studio.example", 443, 5000)
.await
.unwrap();
let mut got = [0u8; 8];
let n = stream.read(&mut got).await.unwrap();
assert_eq!(&got[..n], b"LEFTOVER");
}
#[tokio::test]
async fn tunnel_407_maps_to_auth_failed() {
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut s, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf).await.unwrap();
s.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")
.await
.unwrap();
});
let cfg = ProxyConfig {
scheme: ProxyScheme::Http,
host: "127.0.0.1".into(),
port: addr.port(),
auth: None,
};
let err = connect_through_proxy(&cfg, "studio.example", 443, 5000)
.await
.err()
.expect("expected proxy connect to fail");
assert_eq!(err.code(), "PROXY_AUTH_FAILED");
}
}