Files
weevil/src/sdk/proxy.rs
Eric Ratliff 58f7962a2a v1.1.0-beta.1: add proxy integration tests (62 tests, all green)
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.
2026-02-01 10:21:11 -06:00

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");
}
}