feat: add proxy support for SDK downloads (v1.1.0)
Add --proxy and --no-proxy global flags to control HTTP/HTTPS proxy usage for all network operations (SDK installs, FTC SDK clone/fetch, Android SDK download). Proxy resolution priority: 1. --no-proxy → go direct, ignore everything 2. --proxy <url> → use the specified proxy 3. HTTPS_PROXY / HTTP_PROXY env vars (auto-detected) 4. Nothing → go direct Key implementation details: - reqwest client is always built through ProxyConfig::client() rather than Client::new(), so --no-proxy actively suppresses env-var auto-detection instead of just being a no-op. - git2/libgit2 has its own HTTP transport that doesn't use reqwest. GitProxyGuard is an RAII guard that temporarily sets/clears the HTTPS_PROXY env vars around clone and fetch operations, then restores the previous state on drop. This avoids mutating ~/.gitconfig. - Gradle wrapper reads HTTPS_PROXY natively; no programmatic intervention needed. - All network failure paths now print offline/air-gapped installation instructions automatically, covering manual SDK installs and Gradle distribution download. Closes: v1.1.0 proxy support milestone
This commit is contained in:
309
src/sdk/proxy.rs
Normal file
309
src/sdk/proxy.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
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::*;
|
||||
|
||||
#[test]
|
||||
fn no_proxy_flag_forces_direct() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user