Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
//! A common websocket API that works for native and `wasm` targets.
//! The returned [`WebSocket`] implements [graphql_ws_client::websockets::WebsocketMessage],
//! allowing the returned socket to be used as the backing socket for a graphql websocket client.
//! Unfortunately, this means that this crate depends on [`graphql_ws_client`] as a dependency even
//! though it doesn't assume anything about the underlying protocol of the websocket. To remove this
//! dependency, we would need to move the [`WebsocketMessage`] trait and
//! `graphql_ws_client::wasm_websocket_combined_split` into a common location that both this crate
//! and[`graphql_ws_client`] depend on.
#[cfg_attr(not(target_family = "wasm"), path = "native.rs")]
#[cfg_attr(target_family = "wasm", path = "wasm.rs")]
mod imp;
mod sink_map_err;
use anyhow::anyhow;
#[cfg(not(target_family = "wasm"))]
pub use async_tungstenite::tungstenite::client::IntoClientRequest;
#[cfg(not(target_family = "wasm"))]
use async_tungstenite::tungstenite::http::HeaderValue;
use futures_util::{future, SinkExt, TryStreamExt};
#[cfg(not(target_family = "wasm"))]
use itertools::Itertools;
use thiserror::Error;
#[cfg(not(target_family = "wasm"))]
pub use async_tungstenite::tungstenite;
use crate::sink_map_err::map_err;
// Unfortunately, `anyhow::Error` does not implement `std::error::Error`, which is required by the
// `WebsocketMessage`. To workaround this, we implement a wrapper around `anyhow::Error` using
// `thiserror` as suggested in https://github.com/dtolnay/anyhow/issues/63#issuecomment-591011454.
#[derive(Error, Debug)]
#[error(transparent)]
pub struct Error(#[from] anyhow::Error);
/// The message received / sent to the websocket.
#[derive(Debug)]
pub struct Message(imp::Message);
pub trait WebsocketMessage {
fn new(text: String) -> Self;
fn text(&self) -> Option<&str>;
/// Construct a new message using the `Binary` websocket frame.
fn new_binary(bytes: Vec<u8>) -> Self;
/// Returns the bytes if this message was from a `Binary` websocket frame or `None` if the
/// message was from any other frame type.
fn binary(&self) -> Option<&[u8]>;
/// Construct a new message using the `Text` websocket frame.
fn new_text(text: String) -> Self;
}
impl WebsocketMessage for Message {
fn new(text: String) -> Self {
Message(imp::Message::new(text))
}
fn text(&self) -> Option<&str> {
self.0.text()
}
fn new_binary(bytes: Vec<u8>) -> Self {
Self(imp::Message::new_binary(bytes))
}
fn binary(&self) -> Option<&[u8]> {
self.0.binary()
}
fn new_text(text: String) -> Self {
Self(imp::Message::new_text(text))
}
}
/// A [`WebSocket`] that works natively and on the web. To connect to a websocket
/// with just a URL and an optional set of protocols, use [`Websocket::connect`].
///
/// To connect to a websocket with an enriched client request (e.g. with additional
/// request headers), you can also use [`Websocket::connect`] with an [`http::Request`] but
/// this support is only available for non-wasm targets; custom request headers are not supported
/// for websockets on the web.
///
/// In either case, the caller will have a [`Websocket`] returned.
/// To write or read from the resulting socket, use [`WebSocket::split`].
pub struct WebSocket(imp::WebSocket);
impl WebSocket {
/// Split the [`WebSocket`] into separate [`Stream`] and [`Sink`] objects.
pub async fn split(self) -> (impl Sink, impl Stream) {
let (sink, stream) = self.0.split().await;
let sink = sink.with(|item: Message| future::ok(item.0));
let sink = map_err(sink, |e: imp::Error| Error(anyhow!(e)));
let stream = stream.map_err(|e| Error(anyhow!(e))).map_ok(Message);
(sink, stream)
}
/// Create the [`WebSocket`] by connecting using the provided `request`.
/// For non-wasm WebSockets, the request can be enriched with custom
/// request headers.
#[cfg(not(target_family = "wasm"))]
pub async fn connect(
request: impl IntoClientRequest,
protocols: impl IntoIterator<Item = &str>,
) -> anyhow::Result<Self> {
let mut request = request.into_client_request()?;
let protocols = protocols.into_iter().join(", ");
if !protocols.is_empty() {
request
.headers_mut()
.insert("Sec-WebSocket-Protocol", HeaderValue::from_str(&protocols)?);
}
let socket = imp::connect(request).await?;
Ok(Self(socket))
}
/// Create the [`WebSocket`] by connecting against the provided `url`.
#[cfg(target_family = "wasm")]
pub async fn connect(
url: impl AsRef<str>,
protocols: impl IntoIterator<Item = &str>,
) -> anyhow::Result<Self> {
let socket = imp::connect(url, protocols).await?;
Ok(Self(socket))
}
pub async fn into_graphql_client_builder(self) -> graphql_ws_client::ClientBuilder {
self.0.into_graphql_client_builder().await
}
}
/// Trait that defines a [`Sink`] returned by the websocket.
pub trait Sink: futures::Sink<Message, Error = Error> + Send + Unpin + 'static {}
/// Trait that defines a [`Stream`] returned by the websocket.
pub trait Stream: futures::Stream<Item = Result<Message, Error>> + Send + Unpin + 'static {}
impl<T> Sink for T where T: futures::Sink<Message, Error = Error> + Send + Unpin + 'static {}
impl<T> Stream for T where T: futures::Stream<Item = Result<Message, Error>> + Send + Unpin + 'static
{}
+101
View File
@@ -0,0 +1,101 @@
//! A WebSocket+TLS client based on `async-tungstenite`.
use std::sync::Arc;
use async_tungstenite::{
tokio::{
client_async_tls_with_connector_and_config, connect_async_with_tls_connector, ClientStream,
},
tungstenite::client::IntoClientRequest,
WebSocketStream,
};
use futures::{Sink, Stream};
use futures_util::StreamExt as _;
use rustls_platform_verifier::ConfigVerifierExt;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use crate::WebsocketMessage;
mod proxy;
pub use async_tungstenite::tungstenite::Message;
pub struct WebSocket(WebSocketStream<ClientStream<TcpStream>>);
static CLIENT_CONFIG: std::sync::LazyLock<Result<Arc<rustls::ClientConfig>, rustls::Error>> =
std::sync::LazyLock::new(|| Ok(Arc::new(rustls::ClientConfig::with_platform_verifier()?)));
/// Connects to a WebSocket address (optionally secured by TLS).
///
/// When `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY` environment variables are set,
/// the connection is tunneled through the specified HTTP proxy using the CONNECT method.
/// The `NO_PROXY` environment variable is respected to bypass the proxy for specific hosts.
pub async fn connect(request: impl IntoClientRequest + Unpin) -> anyhow::Result<WebSocket> {
let request = request.into_client_request()?;
let tls_connector = Some(TlsConnector::from(CLIENT_CONFIG.clone()?));
if let Some(proxy_info) = proxy::resolve_proxy(request.uri())? {
log::debug!(
"Using HTTP proxy {}:{} for WebSocket connection to {}",
proxy_info.host,
proxy_info.port,
request.uri(),
);
let tcp_stream = proxy::connect_via_proxy(&proxy_info, request.uri()).await?;
let (stream, _response) =
client_async_tls_with_connector_and_config(request, tcp_stream, tls_connector, None)
.await?;
Ok(WebSocket(stream))
} else {
let stream = connect_async_with_tls_connector(request, tls_connector)
.await?
.0;
Ok(WebSocket(stream))
}
}
impl WebSocket {
pub async fn split(
self,
) -> (
impl Sink<Message, Error = Error>,
impl Stream<Item = Result<Message, Error>>,
) {
self.0.split()
}
pub async fn into_graphql_client_builder(self) -> graphql_ws_client::ClientBuilder {
graphql_ws_client::Client::build(self.0)
}
}
pub type Error = async_tungstenite::tungstenite::Error;
impl WebsocketMessage for Message {
fn new_binary(bytes: Vec<u8>) -> Self {
Self::Binary(bytes)
}
fn binary(&self) -> Option<&[u8]> {
match self {
Message::Binary(bytes) => Some(bytes.as_ref()),
_ => None,
}
}
fn new_text(text: String) -> Self {
Self::Text(text)
}
fn new(text: String) -> Self {
Self::new_text(text)
}
fn text(&self) -> Option<&str> {
match self {
Message::Text(text) => Some(text),
_ => None,
}
}
}
+249
View File
@@ -0,0 +1,249 @@
//! HTTP proxy support for WebSocket connections.
//!
//! Reads standard proxy environment variables (`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`)
//! and establishes tunneled connections via HTTP CONNECT.
//!
//! TODO: Switch to tungstenite's native proxy support once it is available and remove this
//! module: <https://github.com/snapview/tungstenite-rs/pull/530>
use std::env;
use std::time::Duration;
use anyhow::{bail, Context};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use http_body_util::Empty;
use hyper::body::Bytes;
use hyper_util::rt::TokioIo;
use percent_encoding::percent_decode_str;
use tokio::net::TcpStream;
use tokio::time::timeout;
use url::Url;
const PROXY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const PROXY_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
/// Proxy connection info parsed from environment variables.
#[derive(Debug)]
pub struct ProxyInfo {
pub host: String,
pub port: u16,
/// Base64-encoded `user:password` for `Proxy-Authorization: Basic` header.
pub basic_auth: Option<String>,
}
/// Reads proxy environment variables and returns proxy info if a proxy should be used
/// for the given target URI.
///
/// Env var precedence:
/// - For TLS targets (`wss://`): `HTTPS_PROXY` / `https_proxy`, then `ALL_PROXY` / `all_proxy`.
/// - For plain targets (`ws://`): `HTTP_PROXY` / `http_proxy`, then `ALL_PROXY` / `all_proxy`.
/// - `NO_PROXY` / `no_proxy` is checked to bypass the proxy for specific hosts.
pub fn resolve_proxy(uri: &http::Uri) -> anyhow::Result<Option<ProxyInfo>> {
let is_tls = uri.scheme_str() == Some("wss") || uri.scheme_str() == Some("https");
let target_host = uri.host().unwrap_or_default();
let proxy_env = if is_tls {
read_env_var("HTTPS_PROXY").or_else(|| read_env_var("ALL_PROXY"))
} else {
read_env_var("HTTP_PROXY").or_else(|| read_env_var("ALL_PROXY"))
};
let Some((proxy_env_name, proxy_url)) = proxy_env else {
return Ok(None);
};
if is_no_proxy(target_host) {
return Ok(None);
}
parse_proxy_url(&proxy_url)
.with_context(|| format!("Invalid proxy URL configured in {proxy_env_name}"))
.map(Some)
}
/// Establishes a TCP connection through an HTTP proxy using the CONNECT method.
///
/// Uses hyper's HTTP/1 client to send the CONNECT request and then extracts
/// the underlying `TcpStream` via the upgrade mechanism.
pub async fn connect_via_proxy(
proxy: &ProxyInfo,
target_uri: &http::Uri,
) -> anyhow::Result<TcpStream> {
let target_host = target_uri.host().context("Target URI has no host")?;
let is_tls = target_uri.scheme_str() == Some("wss") || target_uri.scheme_str() == Some("https");
let default_port: u16 = if is_tls { 443 } else { 80 };
let target_port = target_uri.port_u16().unwrap_or(default_port);
// 1. TCP connect to the proxy.
let stream = timeout(
PROXY_CONNECT_TIMEOUT,
TcpStream::connect((&*proxy.host, proxy.port)),
)
.await
.context("Timed out connecting to proxy")?
.with_context(|| format!("Failed to connect to proxy {}:{}", proxy.host, proxy.port))?;
// 2. HTTP/1 handshake over the proxy TCP stream.
let (mut sender, conn) = timeout(
PROXY_HANDSHAKE_TIMEOUT,
hyper::client::conn::http1::handshake(TokioIo::new(stream)),
)
.await
.context("Timed out during HTTP handshake with proxy")?
.context("HTTP handshake with proxy failed")?;
// Drive the connection in the background with upgrade support.
tokio::spawn(async move {
if let Err(err) = conn.with_upgrades().await {
log::warn!("Proxy connection driver error: {err}");
}
});
// 3. Build and send the CONNECT request.
let authority = format!("{target_host}:{target_port}");
let mut req = hyper::Request::builder()
.method(hyper::Method::CONNECT)
.uri(&authority)
.header(hyper::header::HOST, &authority)
.body(Empty::<Bytes>::new())
.context("Failed to build CONNECT request")?;
if let Some(credentials) = &proxy.basic_auth {
req.headers_mut().insert(
"proxy-authorization",
format!("Basic {credentials}")
.parse()
.context("Invalid Proxy-Authorization header value")?,
);
}
let response = timeout(PROXY_HANDSHAKE_TIMEOUT, sender.send_request(req))
.await
.context("Timed out waiting for CONNECT response from proxy")?
.context("Failed to send CONNECT request to proxy")?;
if !response.status().is_success() {
bail!("Proxy CONNECT failed with status: {}", response.status());
}
// 4. Upgrade the connection to get the raw stream.
let upgraded = hyper::upgrade::on(response)
.await
.context("Failed to upgrade proxy connection after CONNECT")?;
// 5. Downcast back to the underlying TcpStream.
let downcast = upgraded.downcast::<TokioIo<TcpStream>>().map_err(|_| {
anyhow::anyhow!("Failed to downcast upgraded proxy connection to TcpStream")
})?;
Ok(downcast.io.into_inner())
}
/// Reads an environment variable by its canonical (uppercase) name, falling back to lowercase.
fn read_env_var(uppercase_name: &str) -> Option<(String, String)> {
env::var(uppercase_name)
.ok()
.filter(|v| !v.is_empty())
.map(|value| (uppercase_name.to_string(), value))
.or_else(|| {
let lowercase_name = uppercase_name.to_lowercase();
env::var(&lowercase_name)
.ok()
.filter(|v| !v.is_empty())
.map(|value| (lowercase_name, value))
})
}
/// Returns `true` if `target_host` matches any entry in `NO_PROXY` / `no_proxy`.
///
/// Supported patterns:
/// - `*` matches all hosts.
/// - Exact match (case-insensitive).
/// - Suffix match with leading `.` (e.g. `.example.com` matches `foo.example.com`).
/// - Suffix match without leading `.` (e.g. `example.com` matches `foo.example.com`).
fn is_no_proxy(target_host: &str) -> bool {
let no_proxy = read_env_var("NO_PROXY")
.map(|(_, value)| value)
.unwrap_or_default();
if no_proxy.is_empty() {
return false;
}
let target = target_host.to_lowercase();
for entry in no_proxy.split(',') {
let entry = entry.trim().to_lowercase();
if entry.is_empty() {
continue;
}
if entry == "*" {
return true;
}
if target == entry {
return true;
}
// Suffix match: ".example.com" matches "foo.example.com"
if entry.starts_with('.') && target.ends_with(&entry) {
return true;
}
// Suffix match without leading dot: "example.com" matches "foo.example.com"
if target.ends_with(&format!(".{entry}")) {
return true;
}
}
false
}
/// Parses a proxy URL string into a `ProxyInfo`.
fn parse_proxy_url(raw: &str) -> anyhow::Result<ProxyInfo> {
// Many proxy URLs are specified without a scheme (e.g. "proxy.corp:8080").
// Prepend "http://" if no scheme is present so the URL parser can handle it.
let normalized = if raw.contains("://") {
raw.to_string()
} else {
format!("http://{raw}")
};
let url = Url::parse(&normalized).context("failed to parse proxy URL")?;
match url.scheme() {
"http" => {}
"https" => bail!("HTTPS proxy URLs are not supported"),
scheme => bail!("Unsupported proxy scheme '{scheme}'"),
}
let host = url
.host_str()
.context("proxy URL is missing a host")?
.to_string();
let port = url.port_or_known_default().unwrap_or(8080);
let username = percent_decode_str(url.username())
.decode_utf8()
.context("proxy username contains invalid percent-encoding")?
.into_owned();
let password = url
.password()
.map(|password| {
percent_decode_str(password)
.decode_utf8()
.context("proxy password contains invalid percent-encoding")
})
.transpose()?
.map(|password| password.into_owned());
let basic_auth = if !username.is_empty() || password.is_some() {
let userinfo = format!("{username}:{}", password.unwrap_or_default());
Some(BASE64.encode(userinfo))
} else {
None
};
Ok(ProxyInfo {
host,
port,
basic_auth,
})
}
#[cfg(test)]
#[path = "proxy_tests.rs"]
mod tests;
+391
View File
@@ -0,0 +1,391 @@
use std::sync::Mutex;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use super::*;
/// Guard that ensures proxy-related env vars are cleaned up after each test.
/// Tests that manipulate env vars must hold this lock to avoid races.
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn clear_proxy_env() {
for var in [
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
"NO_PROXY",
"no_proxy",
] {
env::remove_var(var);
}
}
fn wss_uri(host: &str) -> http::Uri {
format!("wss://{host}").parse().unwrap()
}
fn ws_uri(host: &str) -> http::Uri {
format!("ws://{host}").parse().unwrap()
}
fn resolved_proxy_tls(host: &str) -> Option<ProxyInfo> {
resolve_proxy(&wss_uri(host)).expect("proxy resolution should succeed")
}
fn resolved_proxy_plain(host: &str) -> Option<ProxyInfo> {
resolve_proxy(&ws_uri(host)).expect("proxy resolution should succeed")
}
// -- resolve_proxy tests --
#[test]
fn resolve_proxy_returns_none_when_no_env_vars_set() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
assert!(resolved_proxy_tls("example.com").is_none());
assert!(resolved_proxy_plain("example.com").is_none());
}
#[test]
fn resolve_proxy_reads_https_proxy_for_tls() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "http://proxy.corp:3128");
let info = resolved_proxy_tls("example.com").expect("should resolve");
assert_eq!(info.host, "proxy.corp");
assert_eq!(info.port, 3128);
assert!(info.basic_auth.is_none());
// Non-TLS should not use HTTPS_PROXY.
assert!(resolved_proxy_plain("example.com").is_none());
clear_proxy_env();
}
#[test]
fn resolve_proxy_reads_http_proxy_for_non_tls() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTP_PROXY", "http://proxy.corp:8080");
let info = resolved_proxy_plain("example.com").expect("should resolve");
assert_eq!(info.host, "proxy.corp");
assert_eq!(info.port, 8080);
// TLS should not use HTTP_PROXY.
assert!(resolved_proxy_tls("example.com").is_none());
clear_proxy_env();
}
#[test]
fn resolve_proxy_falls_back_to_all_proxy() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("ALL_PROXY", "http://all-proxy.corp:9999");
let tls_info = resolved_proxy_tls("example.com").expect("TLS should fall back to ALL_PROXY");
assert_eq!(tls_info.host, "all-proxy.corp");
assert_eq!(tls_info.port, 9999);
let plain_info =
resolved_proxy_plain("example.com").expect("plain should fall back to ALL_PROXY");
assert_eq!(plain_info.host, "all-proxy.corp");
assert_eq!(plain_info.port, 9999);
clear_proxy_env();
}
#[test]
fn resolve_proxy_prefers_specific_over_all_proxy() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "http://specific:1111");
env::set_var("ALL_PROXY", "http://fallback:2222");
let info = resolved_proxy_tls("example.com").expect("should resolve");
assert_eq!(info.host, "specific");
assert_eq!(info.port, 1111);
clear_proxy_env();
}
#[test]
fn resolve_proxy_reads_lowercase_env_vars() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("https_proxy", "http://lower.corp:4444");
let info = resolved_proxy_tls("example.com").expect("should resolve from lowercase");
assert_eq!(info.host, "lower.corp");
assert_eq!(info.port, 4444);
clear_proxy_env();
}
#[test]
fn resolve_proxy_returns_error_for_malformed_proxy_env() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "://broken");
let err = resolve_proxy(&wss_uri("example.com")).expect_err("malformed proxy env should fail");
let err_msg = format!("{err:#}");
assert!(err_msg.contains("Invalid proxy URL configured in HTTPS_PROXY"));
assert!(err_msg.contains("failed to parse proxy URL"));
clear_proxy_env();
}
#[test]
fn resolve_proxy_rejects_https_proxy_urls() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "https://proxy.corp:443");
let err = resolve_proxy(&wss_uri("example.com")).expect_err("https proxy URLs should fail");
let err_msg = format!("{err:#}");
assert!(err_msg.contains("Invalid proxy URL configured in HTTPS_PROXY"));
assert!(err_msg.contains("HTTPS proxy URLs are not supported"));
clear_proxy_env();
}
// -- NO_PROXY tests --
#[test]
fn no_proxy_exact_match() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "http://proxy:3128");
env::set_var("NO_PROXY", "example.com");
assert!(resolved_proxy_tls("example.com").is_none());
assert!(resolved_proxy_tls("other.com").is_some());
clear_proxy_env();
}
#[test]
fn no_proxy_wildcard() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "http://proxy:3128");
env::set_var("NO_PROXY", "*");
assert!(resolved_proxy_tls("anything.com").is_none());
clear_proxy_env();
}
#[test]
fn no_proxy_suffix_with_dot() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "http://proxy:3128");
env::set_var("NO_PROXY", ".warp.dev");
assert!(resolved_proxy_tls("sessions.app.warp.dev").is_none());
assert!(resolved_proxy_tls("warp.dev").is_some()); // Exact "warp.dev" != ".warp.dev"
assert!(resolved_proxy_tls("other.com").is_some());
clear_proxy_env();
}
#[test]
fn no_proxy_suffix_without_dot() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "http://proxy:3128");
env::set_var("NO_PROXY", "warp.dev");
// "sessions.app.warp.dev" ends with ".warp.dev" → matches
assert!(resolved_proxy_tls("sessions.app.warp.dev").is_none());
// Exact match too
assert!(resolved_proxy_tls("warp.dev").is_none());
assert!(resolved_proxy_tls("notwarp.dev").is_some());
clear_proxy_env();
}
#[test]
fn no_proxy_comma_separated() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "http://proxy:3128");
env::set_var("NO_PROXY", "localhost, 127.0.0.1, .internal.corp");
assert!(resolved_proxy_tls("localhost").is_none());
assert!(resolved_proxy_tls("127.0.0.1").is_none());
assert!(resolved_proxy_tls("foo.internal.corp").is_none());
assert!(resolved_proxy_tls("external.com").is_some());
clear_proxy_env();
}
#[test]
fn no_proxy_case_insensitive() {
let _lock = ENV_LOCK.lock();
clear_proxy_env();
env::set_var("HTTPS_PROXY", "http://proxy:3128");
env::set_var("NO_PROXY", "Example.COM");
assert!(resolved_proxy_tls("example.com").is_none());
assert!(resolved_proxy_tls("EXAMPLE.COM").is_none());
clear_proxy_env();
}
// -- parse_proxy_url tests --
#[test]
fn parse_proxy_url_with_scheme() {
let info = parse_proxy_url("http://proxy.corp:3128").expect("should parse");
assert_eq!(info.host, "proxy.corp");
assert_eq!(info.port, 3128);
assert!(info.basic_auth.is_none());
}
#[test]
fn parse_proxy_url_without_scheme() {
let info = parse_proxy_url("proxy.corp:8080").expect("should parse");
assert_eq!(info.host, "proxy.corp");
assert_eq!(info.port, 8080);
}
#[test]
fn parse_proxy_url_default_port() {
let info = parse_proxy_url("http://proxy.corp").expect("should parse");
assert_eq!(info.port, 80);
}
#[test]
fn parse_proxy_url_explicit_default_port() {
// Explicit :80 should resolve to 80, not be swallowed by the URL parser.
let info = parse_proxy_url("http://proxy.corp:80").expect("should parse");
assert_eq!(info.port, 80);
}
#[test]
fn parse_proxy_url_with_credentials() {
let info = parse_proxy_url("http://user:pass@proxy.corp:3128").expect("should parse");
assert_eq!(info.host, "proxy.corp");
assert_eq!(info.port, 3128);
let decoded = String::from_utf8(
base64::engine::general_purpose::STANDARD
.decode(info.basic_auth.as_ref().unwrap())
.unwrap(),
)
.unwrap();
assert_eq!(decoded, "user:pass");
}
#[test]
fn parse_proxy_url_decodes_percent_encoded_credentials() {
let info = parse_proxy_url("http://user%40name:p%3Ass@proxy.corp:3128").expect("should parse");
let decoded = String::from_utf8(
base64::engine::general_purpose::STANDARD
.decode(info.basic_auth.as_ref().expect("basic auth should exist"))
.expect("basic auth should be valid base64"),
)
.expect("decoded basic auth should be valid UTF-8");
assert_eq!(decoded, "user@name:p:ss");
}
// -- connect_via_proxy integration test with mock proxy --
#[tokio::test]
async fn connect_via_proxy_sends_correct_connect_request() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let proxy_info = ProxyInfo {
host: "127.0.0.1".to_string(),
port: addr.port(),
basic_auth: None,
};
// Spawn a mock proxy that reads the CONNECT request and responds with 200.
let mock_proxy = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 1024];
let n = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..n]).to_string();
socket
.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n")
.await
.unwrap();
request
});
let target_uri: http::Uri = "wss://target.example.com:443".parse().unwrap();
let result = connect_via_proxy(&proxy_info, &target_uri).await;
assert!(result.is_ok(), "connect_via_proxy should succeed");
let request_sent = mock_proxy.await.unwrap();
let request_lower = request_sent.to_lowercase();
assert!(request_sent.starts_with("CONNECT target.example.com:443 HTTP/1.1\r\n"));
assert!(
request_lower.contains("host: target.example.com:443\r\n"),
"Request should contain Host header: {request_sent}"
);
}
#[tokio::test]
async fn connect_via_proxy_sends_auth_header() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let proxy_info = ProxyInfo {
host: "127.0.0.1".to_string(),
port: addr.port(),
basic_auth: Some(BASE64.encode("user:secret")),
};
let mock_proxy = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 1024];
let n = socket.read(&mut buf).await.unwrap();
let request = String::from_utf8_lossy(&buf[..n]).to_string();
socket.write_all(b"HTTP/1.1 200 OK\r\n\r\n").await.unwrap();
request
});
let target_uri: http::Uri = "wss://host.example.com:8443".parse().unwrap();
let result = connect_via_proxy(&proxy_info, &target_uri).await;
assert!(result.is_ok());
let request_sent = mock_proxy.await.unwrap();
let expected_auth = format!(
"proxy-authorization: Basic {}",
BASE64.encode("user:secret")
);
assert!(
request_sent.contains(&expected_auth),
"Request should contain auth header: {request_sent}"
);
}
#[tokio::test]
async fn connect_via_proxy_fails_on_407() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let proxy_info = ProxyInfo {
host: "127.0.0.1".to_string(),
port: addr.port(),
basic_auth: None,
};
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 1024];
let _ = socket.read(&mut buf).await.unwrap();
socket
.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")
.await
.unwrap();
});
let target_uri: http::Uri = "wss://host.example.com:443".parse().unwrap();
let result = connect_via_proxy(&proxy_info, &target_uri).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("407"),
"Error should mention 407 status: {err_msg}"
);
}
+87
View File
@@ -0,0 +1,87 @@
use core::pin::Pin;
use futures::{Sink, Stream};
use futures_util::stream::FusedStream;
use pin_project::pin_project;
use std::task::{Context, Poll};
/// Maps the error returned by the [`Sink`] using the provided `err` function.
pub fn map_err<S, I, E>(sink: S, err: impl FnMut(S::Error) -> E) -> impl Sink<I, Error = E>
where
S: Sink<I>,
{
SinkMapErr::new(sink, err)
}
/// Helper struct to map the [`Err`] of an underlying [`Sink`].
/// This is a fork of the the `SinkMapErr` defined within `futures-util`
/// (https://docs.rs/futures/latest/futures/sink/struct.SinkMapErr.html) except that it does _not_
/// panic if the caller tries to write to the sink after a previous attempt to write returned an
/// error. See <https://github.com/rust-lang/futures-rs/issues/2108> for more details about the
/// issue with the original `SinkMapErr` struct.
#[pin_project]
struct SinkMapErr<Si, F> {
#[pin]
sink: Si,
err_function: F,
}
impl<Si, F> SinkMapErr<Si, F> {
fn new(sink: Si, err_function: F) -> Self {
Self { sink, err_function }
}
}
impl<Si, F, E, Item> Sink<Item> for SinkMapErr<Si, F>
where
Si: Sink<Item>,
F: FnMut(Si::Error) -> E,
{
type Error = E;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
let project = self.as_mut().project();
let err_function = project.err_function;
project.sink.poll_ready(cx).map_err(err_function)
}
fn start_send(mut self: Pin<&mut Self>, item: Item) -> Result<(), Self::Error> {
let project = self.as_mut().project();
let err_function = project.err_function;
project.sink.start_send(item).map_err(err_function)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
let project = self.as_mut().project();
let err_function = project.err_function;
project.sink.poll_flush(cx).map_err(err_function)
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
let project = self.as_mut().project();
let err_function = project.err_function;
project.sink.poll_close(cx).map_err(err_function)
}
}
/// Implement [`Stream`] by forwarding calls to the underlying [`Sink`].
impl<S: Stream, F> Stream for SinkMapErr<S, F> {
type Item = S::Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.project().sink.poll_next(cx)
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.sink.size_hint()
}
}
impl<S: FusedStream, F> FusedStream for SinkMapErr<S, F> {
fn is_terminated(&self) -> bool {
self.sink.is_terminated()
}
}
#[cfg(test)]
#[path = "sink_map_err_tests.rs"]
mod tests;
@@ -0,0 +1,39 @@
use super::*;
use futures_test_sink::SinkMock;
use futures_util::SinkExt;
use thiserror::Error;
#[derive(Error, PartialEq, Debug, Copy, Clone)]
#[error("Unable to send to sink")]
struct UnmappedError(u8);
#[derive(Error, PartialEq, Debug, Copy, Clone)]
#[error("Unable to send to sink")]
struct MappedError(u8);
#[tokio::test]
async fn test_map_err() {
let poll_results = vec![
Poll::Ready(Ok(())),
Poll::Pending,
Poll::Ready(Err(UnmappedError(0))),
Poll::Ready(Err(UnmappedError(1))),
]
.into_iter();
let mut sink = SinkMock::with_flush_feedback(poll_results.clone());
// The unmapped sink should return an `UnmappedError`.
assert_eq!(Ok(()), sink.send(()).await);
assert_eq!(Err(UnmappedError(0)), sink.send(()).await);
assert_eq!(Err(UnmappedError(1)), sink.send(()).await);
let mut mapped_sink = map_err(SinkMock::with_flush_feedback(poll_results), |err| {
MappedError(err.0)
});
// After mapping the item type should be unchanged, but the error should be a `MappedError`.
assert_eq!(Ok(()), mapped_sink.send(()).await);
assert_eq!(Err(MappedError(0)), mapped_sink.send(()).await);
assert_eq!(Err(MappedError(1)), mapped_sink.send(()).await);
}
+69
View File
@@ -0,0 +1,69 @@
use futures::{Sink, Stream, StreamExt};
use itertools::Itertools;
use ws_stream_wasm::{WsErr, WsMessage, WsMeta};
pub use ws_stream_wasm::WsMessage as Message;
use crate::WebsocketMessage;
pub async fn connect(
url: impl AsRef<str>,
protocols: impl IntoIterator<Item = &str>,
) -> anyhow::Result<WebSocket> {
let protocols = protocols.into_iter().collect_vec();
let (meta, stream) = WsMeta::connect(url, (!protocols.is_empty()).then_some(protocols)).await?;
Ok(WebSocket { stream, meta })
}
pub type Error = WsErr;
pub struct WebSocket {
stream: ws_stream_wasm::WsStream,
meta: WsMeta,
}
impl WebSocket {
pub async fn split(
self,
) -> (
impl Sink<Message, Error = WsErr>,
impl Stream<Item = Result<Message, WsErr>>,
) {
let (sink, stream) = self.stream.split();
(sink, stream.map(Ok::<_, ws_stream_wasm::WsErr>))
}
pub async fn into_graphql_client_builder(self) -> graphql_ws_client::ClientBuilder {
graphql_ws_client::Client::build(
graphql_ws_client::ws_stream_wasm::Connection::new((self.meta, self.stream)).await,
)
}
}
impl WebsocketMessage for WsMessage {
fn new_binary(bytes: Vec<u8>) -> Self {
Self::Binary(bytes)
}
fn binary(&self) -> Option<&[u8]> {
match self {
Self::Binary(bytes) => Some(bytes.as_ref()),
_ => None,
}
}
fn new(text: String) -> Self {
Self::new_text(text)
}
fn new_text(text: String) -> Self {
Self::Text(text)
}
fn text(&self) -> Option<&str> {
match self {
WsMessage::Text(text) => Some(text.as_str()),
_ => None,
}
}
}