use std::time::Duration;
pub const LOOPBACK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
pub const LOOPBACK_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
pub fn loopback_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder().no_proxy()
}
pub fn loopback_client() -> reqwest::Result<reqwest::Client> {
loopback_client_builder()
.connect_timeout(LOOPBACK_CONNECT_TIMEOUT)
.timeout(LOOPBACK_REQUEST_TIMEOUT)
.build()
}
#[cfg(feature = "blocking-http")]
pub fn blocking_loopback_client_builder() -> reqwest::blocking::ClientBuilder {
reqwest::blocking::Client::builder().no_proxy()
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
async fn stub_server() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback stub");
let addr = listener.local_addr().expect("stub addr").to_string();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.await;
});
}
});
addr
}
fn dead_addr() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind to free a port");
let addr = listener.local_addr().expect("dead addr").to_string();
drop(listener);
addr
}
fn with_http_proxy<T>(value: &str, body: impl FnOnce() -> T) -> T {
let _env = crate::data_dir::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("HTTP_PROXY").ok();
unsafe { std::env::set_var("HTTP_PROXY", value) };
let out = body();
unsafe {
match previous {
Some(v) => std::env::set_var("HTTP_PROXY", v),
None => std::env::remove_var("HTTP_PROXY"),
}
}
out
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn loopback_client_ignores_exported_http_proxy() {
let addr = stub_server().await;
let url = format!("http://{addr}/health");
let proxy = format!("http://{}", dead_addr());
let (leaky, guarded) = with_http_proxy(&proxy, || {
let leaky = reqwest::Client::builder()
.connect_timeout(Duration::from_millis(500))
.timeout(Duration::from_millis(500))
.build()
.expect("bare client builds");
let guarded = loopback_client().expect("loopback client builds");
(leaky, guarded)
});
let leaked = leaky.get(&url).send().await;
let reached = guarded.get(&url).send().await;
assert!(
leaked.is_err(),
"a client WITHOUT .no_proxy() must be diverted through HTTP_PROXY — \
that diversion IS the #4392 mechanism; got {leaked:?}"
);
assert!(
reached.is_ok_and(|r| r.status().is_success()),
"loopback_client() must reach a loopback peer with HTTP_PROXY exported"
);
}
#[cfg(feature = "blocking-http")]
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn blocking_loopback_client_ignores_exported_http_proxy() {
let addr = stub_server().await;
let url = format!("http://{addr}/health");
let proxy = format!("http://{}", dead_addr());
let (leaked, reached) = tokio::task::spawn_blocking(move || {
with_http_proxy(&proxy, || {
let leaky = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_millis(500))
.timeout(Duration::from_millis(500))
.build()
.expect("bare blocking client builds");
let guarded = blocking_loopback_client_builder()
.connect_timeout(LOOPBACK_CONNECT_TIMEOUT)
.timeout(LOOPBACK_REQUEST_TIMEOUT)
.build()
.expect("blocking loopback client builds");
(
leaky.get(&url).send().is_err(),
guarded
.get(&url)
.send()
.is_ok_and(|r| r.status().is_success()),
)
})
})
.await
.expect("blocking probe thread");
assert!(
leaked,
"a blocking client WITHOUT .no_proxy() must be diverted through HTTP_PROXY"
);
assert!(
reached,
"the blocking loopback builder must reach a loopback peer with HTTP_PROXY exported"
);
}
#[test]
fn loopback_client_builds() {
drop(loopback_client().expect("loopback client builds"));
}
}