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;
const STUB_HEAD_LIMIT: usize = 8 * 1024;
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::{AsyncReadExt, AsyncWriteExt};
let mut head = Vec::with_capacity(512);
let mut chunk = [0u8; 512];
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(n) => head.extend_from_slice(&chunk[..n]),
}
if head.len() >= STUB_HEAD_LIMIT {
break;
}
}
let _ = stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
)
.await;
let _ = stream.shutdown().await;
});
}
});
addr
}
#[tokio::test]
async fn stub_server_answers_only_after_the_request_arrives() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let addr = stub_server().await;
let mut stream = tokio::net::TcpStream::connect(&addr)
.await
.expect("connect to stub");
let mut early = [0u8; 64];
let premature =
tokio::time::timeout(Duration::from_millis(300), stream.read(&mut early)).await;
assert!(
premature.is_err(),
"the stub must not answer before the request arrives; got {premature:?}"
);
stream
.write_all(b"GET /health HTTP/1.1\r\nHost: stub\r\n\r\n")
.await
.expect("send request");
let mut response = Vec::new();
let read =
tokio::time::timeout(LOOPBACK_REQUEST_TIMEOUT, stream.read_to_end(&mut response))
.await
.expect("stub answers within the request bound");
assert!(
read.is_ok(),
"the stub must end with FIN, not RST: {read:?}"
);
let text = String::from_utf8_lossy(&response);
assert!(
text.starts_with("HTTP/1.1 200 OK\r\n") && text.ends_with("\r\n\r\nok"),
"the client must get the whole 200; got {text:?}"
);
}
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"));
}
}