Nine end-to-end tests for the proxy feature: 6 network tests exercising every proxy code path through a real hyper forward proxy (TestProxy) and a mockito origin, plus 3 CLI tests verifying flag parsing and error handling. TestProxy binds to 127.0.0.1:0, forwards in absolute-form, counts requests via an atomic so we can assert traffic actually traversed the proxy. Key issues resolved during implementation: - ENV_MUTEX serializes all tests that mutate HTTPS_PROXY/HTTP_PROXY in both the unit test module and the integration suite. Without it, parallel test execution within a single binary produces nondeterministic failures. - reqwest's blocking::Client owns an internal tokio Runtime. Dropping it inside a #[tokio::test] async fn panics on tokio >= 1.49. All reqwest work runs inside spawn_blocking so the Client drops on a thread-pool thread where that's permitted. - service_fn's closure can't carry a return-type annotation, and async blocks don't support one either. The handler is extracted to a named async fn (proxy_handler) so the compiler can see the concrete Result<Response, Infallible> and satisfy serve_connection's Error bound.
322 lines
13 KiB
Rust
322 lines
13 KiB
Rust
use colored::*;
|
|
use reqwest::blocking;
|
|
use reqwest::Url;
|
|
|
|
/// Where the proxy URL came from — used for status messages.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ProxySource {
|
|
/// --proxy <url> on the command line
|
|
Flag,
|
|
/// HTTPS_PROXY or HTTP_PROXY environment variable
|
|
Env(String),
|
|
}
|
|
|
|
/// Resolved proxy configuration. A `None` url means "go direct".
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProxyConfig {
|
|
pub url: Option<Url>,
|
|
pub source: Option<ProxySource>,
|
|
}
|
|
|
|
impl ProxyConfig {
|
|
/// Resolve proxy with this priority:
|
|
/// 1. --no-proxy → direct, ignore everything
|
|
/// 2. --proxy <url> → use that URL
|
|
/// 3. HTTPS_PROXY / HTTP_PROXY env vars
|
|
/// 4. Nothing → direct
|
|
pub fn resolve(proxy_flag: Option<&str>, no_proxy: bool) -> Result<Self, anyhow::Error> {
|
|
if no_proxy {
|
|
return Ok(Self { url: None, source: None });
|
|
}
|
|
|
|
if let Some(raw) = proxy_flag {
|
|
let url = Url::parse(raw)
|
|
.map_err(|e| anyhow::anyhow!("Invalid --proxy URL '{}': {}", raw, e))?;
|
|
return Ok(Self { url: Some(url), source: Some(ProxySource::Flag) });
|
|
}
|
|
|
|
// Walk the env vars in priority order
|
|
for var in &["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"] {
|
|
if let Ok(val) = std::env::var(var) {
|
|
if val.is_empty() { continue; }
|
|
let url = Url::parse(&val)
|
|
.map_err(|e| anyhow::anyhow!("Invalid {} env var '{}': {}", var, val, e))?;
|
|
return Ok(Self {
|
|
url: Some(url),
|
|
source: Some(ProxySource::Env(var.to_string())),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(Self { url: None, source: None })
|
|
}
|
|
|
|
/// True when the user explicitly passed --proxy (as opposed to env-var pickup).
|
|
/// Used for distinguishing "you asked for this proxy and it failed" from
|
|
/// "we picked up an ambient proxy from your environment" in error paths.
|
|
#[allow(dead_code)]
|
|
pub fn is_explicit(&self) -> bool {
|
|
matches!(self.source, Some(ProxySource::Flag))
|
|
}
|
|
|
|
/// Human-readable description of where the proxy came from, for status output.
|
|
pub fn source_description(&self) -> String {
|
|
match &self.source {
|
|
Some(ProxySource::Flag) => "--proxy flag".to_string(),
|
|
Some(ProxySource::Env(var)) => format!("{} env var", var),
|
|
None => "none".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Print a one-line proxy status line (used in setup output).
|
|
pub fn print_status(&self) {
|
|
match &self.url {
|
|
Some(url) => println!(
|
|
" {} Proxy: {} ({})",
|
|
"✓".green(),
|
|
url.as_str().bright_white(),
|
|
self.source_description()
|
|
),
|
|
None => println!(
|
|
" {} Proxy: direct (no proxy)",
|
|
"○".bright_black()
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Build a reqwest blocking Client that honours this proxy config.
|
|
///
|
|
/// * `Some(url)` → all HTTP/HTTPS traffic goes through that proxy.
|
|
/// * `None` → direct, no proxy at all.
|
|
///
|
|
/// We always go through the builder (never plain `Client::new()`) because
|
|
/// `Client::new()` silently picks up env-var proxies. When the user says
|
|
/// `--no-proxy` we need to actively disable that.
|
|
pub fn client(&self) -> anyhow::Result<blocking::Client> {
|
|
let mut builder = blocking::ClientBuilder::new();
|
|
|
|
match &self.url {
|
|
Some(url) => {
|
|
builder = builder.proxy(reqwest::Proxy::all(url.clone())?);
|
|
}
|
|
None => {
|
|
// Actively suppress env-var auto-detection.
|
|
builder = builder.no_proxy();
|
|
}
|
|
}
|
|
|
|
builder.build().map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {}", e))
|
|
}
|
|
}
|
|
|
|
/// RAII guard that temporarily sets HTTPS_PROXY / HTTP_PROXY for the lifetime
|
|
/// of the guard, then restores the previous values on drop.
|
|
///
|
|
/// libgit2 (the C library behind the `git2` crate) reads these env vars
|
|
/// directly for its HTTP transport. This is the cleanest way to make
|
|
/// `git2::Repository::clone` and `remote.fetch()` honour a `--proxy` flag
|
|
/// without touching the user's global `~/.gitconfig`.
|
|
///
|
|
/// When the ProxyConfig has no URL (direct / --no-proxy) the guard *clears*
|
|
/// the vars so libgit2 won't accidentally pick up an ambient proxy the user
|
|
/// didn't intend for this operation.
|
|
pub struct GitProxyGuard {
|
|
prev_https: Option<String>,
|
|
prev_http: Option<String>,
|
|
}
|
|
|
|
impl GitProxyGuard {
|
|
pub fn new(config: &ProxyConfig) -> Self {
|
|
let prev_https = std::env::var("HTTPS_PROXY").ok();
|
|
let prev_http = std::env::var("HTTP_PROXY").ok();
|
|
|
|
match &config.url {
|
|
Some(url) => {
|
|
let s = url.as_str();
|
|
std::env::set_var("HTTPS_PROXY", s);
|
|
std::env::set_var("HTTP_PROXY", s);
|
|
}
|
|
None => {
|
|
std::env::remove_var("HTTPS_PROXY");
|
|
std::env::remove_var("HTTP_PROXY");
|
|
}
|
|
}
|
|
|
|
Self { prev_https, prev_http }
|
|
}
|
|
}
|
|
|
|
impl Drop for GitProxyGuard {
|
|
fn drop(&mut self) {
|
|
match &self.prev_https {
|
|
Some(v) => std::env::set_var("HTTPS_PROXY", v),
|
|
None => std::env::remove_var("HTTPS_PROXY"),
|
|
}
|
|
match &self.prev_http {
|
|
Some(v) => std::env::set_var("HTTP_PROXY", v),
|
|
None => std::env::remove_var("HTTP_PROXY"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Print clear, actionable instructions for obtaining dependencies without
|
|
/// an internet connection. Called whenever a network download fails so the
|
|
/// user always sees their escape hatch.
|
|
pub fn print_offline_instructions() {
|
|
println!();
|
|
println!("{}", "── Offline / Air-Gapped Installation ──────────────────────".bright_yellow().bold());
|
|
println!();
|
|
println!("If you have no internet access (or the proxy is not working),");
|
|
println!("you can obtain the required SDKs on a connected machine and");
|
|
println!("copy them over.");
|
|
println!();
|
|
println!("{}", "1. FTC SDK (git clone)".bright_cyan().bold());
|
|
println!(" On a connected machine:");
|
|
println!(" git clone https://github.com/FIRST-Tech-Challenge/FtcRobotController.git");
|
|
println!(" cd FtcRobotController");
|
|
println!(" git checkout v10.1.1");
|
|
println!();
|
|
println!(" Copy the entire FtcRobotController/ directory to this machine");
|
|
println!(" at ~/.weevil/ftc-sdk/ (or wherever your .weevil.toml points).");
|
|
println!();
|
|
println!("{}", "2. Android SDK (command-line tools)".bright_cyan().bold());
|
|
println!(" Download the zip for your OS from a connected machine:");
|
|
println!(" Linux: https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip");
|
|
println!(" macOS: https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip");
|
|
println!(" Windows: https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip");
|
|
println!();
|
|
println!(" Extract to ~/.weevil/android-sdk/, then run sdkmanager:");
|
|
println!(" ./cmdline-tools/latest/bin/sdkmanager \\");
|
|
println!(" platform-tools platforms;android-34 build-tools;34.0.0");
|
|
println!();
|
|
println!(" Copy the resulting android-sdk/ directory to this machine.");
|
|
println!();
|
|
println!("{}", "3. Gradle distribution".bright_cyan().bold());
|
|
println!(" Gradle fetches its own distribution the first time ./gradlew");
|
|
println!(" runs. If that fails offline, download manually:");
|
|
println!(" https://services.gradle.org/distributions/gradle-8.9-bin.zip");
|
|
println!(" Extract into ~/.gradle/wrapper/dists/gradle-8.9-bin/");
|
|
println!(" (the exact subdirectory is printed by gradlew on failure).");
|
|
println!();
|
|
println!("{}", "4. Proxy quick reference".bright_cyan().bold());
|
|
println!(" • Use a specific proxy: weevil --proxy http://proxy:3128 sdk install");
|
|
println!(" • Skip the proxy entirely: weevil --no-proxy sdk install");
|
|
println!(" • Gradle also reads HTTPS_PROXY / HTTP_PROXY, so set those");
|
|
println!(" in your shell before running ./gradlew if the build needs a proxy.");
|
|
println!();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::Mutex;
|
|
|
|
// Env vars are process-global. cargo test runs tests in parallel within
|
|
// a single binary, so any test that sets/removes HTTPS_PROXY or HTTP_PROXY
|
|
// must hold this lock for its entire duration.
|
|
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
|
|
|
#[test]
|
|
fn no_proxy_flag_forces_direct() {
|
|
let _env_lock = ENV_MUTEX.lock().unwrap();
|
|
std::env::set_var("HTTPS_PROXY", "http://proxy.example.com:3128");
|
|
let config = ProxyConfig::resolve(None, true).unwrap();
|
|
assert!(config.url.is_none());
|
|
std::env::remove_var("HTTPS_PROXY");
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_flag_overrides_env() {
|
|
let _env_lock = ENV_MUTEX.lock().unwrap();
|
|
std::env::set_var("HTTPS_PROXY", "http://env-proxy.example.com:3128");
|
|
let config = ProxyConfig::resolve(Some("http://flag-proxy.example.com:8080"), false).unwrap();
|
|
assert_eq!(config.url.as_ref().unwrap().host_str(), Some("flag-proxy.example.com"));
|
|
assert!(config.is_explicit());
|
|
std::env::remove_var("HTTPS_PROXY");
|
|
}
|
|
|
|
#[test]
|
|
fn picks_up_env_var() {
|
|
let _env_lock = ENV_MUTEX.lock().unwrap();
|
|
std::env::remove_var("HTTPS_PROXY");
|
|
std::env::remove_var("https_proxy");
|
|
std::env::set_var("HTTP_PROXY", "http://env-proxy.example.com:3128");
|
|
let config = ProxyConfig::resolve(None, false).unwrap();
|
|
assert_eq!(config.url.as_ref().unwrap().host_str(), Some("env-proxy.example.com"));
|
|
assert_eq!(config.source, Some(ProxySource::Env("HTTP_PROXY".to_string())));
|
|
std::env::remove_var("HTTP_PROXY");
|
|
}
|
|
|
|
#[test]
|
|
fn direct_when_nothing_set() {
|
|
let _env_lock = ENV_MUTEX.lock().unwrap();
|
|
std::env::remove_var("HTTPS_PROXY");
|
|
std::env::remove_var("https_proxy");
|
|
std::env::remove_var("HTTP_PROXY");
|
|
std::env::remove_var("http_proxy");
|
|
let config = ProxyConfig::resolve(None, false).unwrap();
|
|
assert!(config.url.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_garbage_url() {
|
|
let result = ProxyConfig::resolve(Some("not a url at all"), false);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn client_builds_with_proxy() {
|
|
let config = ProxyConfig::resolve(Some("http://proxy.example.com:3128"), false).unwrap();
|
|
assert!(config.client().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn client_builds_direct() {
|
|
let _env_lock = ENV_MUTEX.lock().unwrap();
|
|
std::env::remove_var("HTTPS_PROXY");
|
|
std::env::remove_var("https_proxy");
|
|
std::env::remove_var("HTTP_PROXY");
|
|
std::env::remove_var("http_proxy");
|
|
let config = ProxyConfig::resolve(None, true).unwrap();
|
|
assert!(config.client().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn git_proxy_guard_sets_and_restores() {
|
|
let _env_lock = ENV_MUTEX.lock().unwrap();
|
|
std::env::set_var("HTTPS_PROXY", "http://original:1111");
|
|
std::env::set_var("HTTP_PROXY", "http://original:2222");
|
|
|
|
let config = ProxyConfig::resolve(Some("http://guarded:9999"), false).unwrap();
|
|
{
|
|
let _guard = GitProxyGuard::new(&config);
|
|
// Url::parse normalises — trailing slash is expected
|
|
assert_eq!(std::env::var("HTTPS_PROXY").unwrap(), "http://guarded:9999/");
|
|
assert_eq!(std::env::var("HTTP_PROXY").unwrap(), "http://guarded:9999/");
|
|
}
|
|
assert_eq!(std::env::var("HTTPS_PROXY").unwrap(), "http://original:1111");
|
|
assert_eq!(std::env::var("HTTP_PROXY").unwrap(), "http://original:2222");
|
|
|
|
std::env::remove_var("HTTPS_PROXY");
|
|
std::env::remove_var("HTTP_PROXY");
|
|
}
|
|
|
|
#[test]
|
|
fn git_proxy_guard_clears_for_direct() {
|
|
let _env_lock = ENV_MUTEX.lock().unwrap();
|
|
std::env::set_var("HTTPS_PROXY", "http://should-be-cleared:1111");
|
|
std::env::set_var("HTTP_PROXY", "http://should-be-cleared:2222");
|
|
|
|
let config = ProxyConfig { url: None, source: None };
|
|
{
|
|
let _guard = GitProxyGuard::new(&config);
|
|
assert!(std::env::var("HTTPS_PROXY").is_err());
|
|
assert!(std::env::var("HTTP_PROXY").is_err());
|
|
}
|
|
assert_eq!(std::env::var("HTTPS_PROXY").unwrap(), "http://should-be-cleared:1111");
|
|
assert_eq!(std::env::var("HTTP_PROXY").unwrap(), "http://should-be-cleared:2222");
|
|
|
|
std::env::remove_var("HTTPS_PROXY");
|
|
std::env::remove_var("HTTP_PROXY");
|
|
}
|
|
} |