use std::env; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; const PROXY_ENV_PRIORITY: [&str; 6] = [ "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy", ]; const NO_PROXY_ENV_PRIORITY: [&str; 2] = ["NO_PROXY", "no_proxy"]; /// Proxy selection mode for official HTTP transfers executed through `curl`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CurlProxyMode { /// Detect proxy settings from the current process environment. Auto, /// Force direct connections and ignore proxy environment variables. Disabled, /// Force a specific proxy URL. Url(String), } /// Proxy configuration for official HTTP transfers executed through `curl`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CurlProxyConfig { mode: CurlProxyMode, } impl CurlProxyConfig { /// Creates automatic proxy detection. pub fn auto() -> Self { Self { mode: CurlProxyMode::Auto, } } /// Creates a direct-connection configuration. pub fn disabled() -> Self { Self { mode: CurlProxyMode::Disabled, } } /// Creates a forced proxy configuration. pub fn url(url: impl Into) -> Self { Self { mode: CurlProxyMode::Url(url.into()), } } /// Returns the proxy mode. pub fn mode(&self) -> &CurlProxyMode { &self.mode } } impl Default for CurlProxyConfig { fn default() -> Self { Self::auto() } } /// Resolved proxy settings for one `curl` command. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedCurlProxy { /// Whether a proxy URL will be passed to `curl`. pub enabled: bool, /// Whether direct mode was explicitly requested. pub disabled: bool, /// Proxy URL, when one is selected. pub url: Option, /// Where this proxy decision came from. pub source: String, /// `NO_PROXY`/`no_proxy` value preserved for `curl`, when present. pub no_proxy: Option, } impl ResolvedCurlProxy { /// Returns a human-readable summary with credentials redacted. pub fn human_summary(&self) -> String { if self.disabled { return format!("代理已关闭;来源={}", self.source); } if let Some(url) = self.url.as_ref() { let no_proxy = self .no_proxy .as_ref() .map(|value| format!(";no_proxy={value}")) .unwrap_or_default(); return format!( "使用代理 {};来源={}{}", redact_proxy_url(url), self.source, no_proxy ); } format!("未检测到代理;来源={}", self.source) } } /// Resolves the proxy setting that should be applied to `curl`. pub fn resolve_curl_proxy(config: &CurlProxyConfig) -> ResolvedCurlProxy { let pairs = proxy_env_pairs(); resolve_curl_proxy_from_pairs(config, &pairs) } /// Redacts user info from a proxy URL for diagnostics and logs. /// /// `curl` accepts proxy strings without an explicit scheme (defaulting to /// `http://`), so credentials such as `user:secret@host:port` must be redacted /// even when no `://` separator is present. pub fn redact_proxy_url(url: &str) -> String { if let Some((scheme, rest)) = url.split_once("://") { let Some(at_index) = rest.find('@') else { return url.to_string(); }; return format!("{scheme}://@{}", &rest[at_index + 1..]); } let Some(at_index) = url.find('@') else { return url.to_string(); }; format!("@{}", &url[at_index + 1..]) } fn proxy_env_pairs() -> Vec<(String, String)> { PROXY_ENV_PRIORITY .iter() .chain(NO_PROXY_ENV_PRIORITY.iter()) .filter_map(|name| { env::var(name) .ok() .map(|value| ((*name).to_string(), value)) }) .collect() } fn resolve_curl_proxy_from_pairs( config: &CurlProxyConfig, pairs: &[(String, String)], ) -> ResolvedCurlProxy { match config.mode() { CurlProxyMode::Auto => { let no_proxy = first_env_value(pairs, &NO_PROXY_ENV_PRIORITY).map(|(_, value)| value); if let Some((name, url)) = first_env_value(pairs, &PROXY_ENV_PRIORITY) { ResolvedCurlProxy { enabled: true, disabled: false, url: Some(url), source: name, no_proxy, } } else { ResolvedCurlProxy { enabled: false, disabled: false, url: None, source: "auto".to_string(), no_proxy, } } } CurlProxyMode::Disabled => ResolvedCurlProxy { enabled: false, disabled: true, url: None, source: "--no-proxy".to_string(), no_proxy: None, }, CurlProxyMode::Url(url) => ResolvedCurlProxy { enabled: true, disabled: false, url: Some(url.clone()), source: "--proxy".to_string(), no_proxy: first_env_value(pairs, &NO_PROXY_ENV_PRIORITY).map(|(_, value)| value), }, } } fn first_env_value(pairs: &[(String, String)], priority: &[&str]) -> Option<(String, String)> { priority.iter().find_map(|name| { pairs .iter() .find(|(candidate, value)| candidate == name && !value.trim().is_empty()) .map(|(candidate, value)| (candidate.clone(), value.clone())) }) } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CurlFailureKind { HttpForbidden, HttpNotFound, HttpClientError, HttpTooManyRequests, HttpServerError, HttpUnknown, Dns, Connect, Timeout, Tls, Interrupted, Network, /// 代理自身故障:无法解析/连接代理,或代理返回 407 认证失败等。 Proxy, ProcessFailed, Unknown, } impl CurlFailureKind { pub(crate) fn as_str(&self) -> &'static str { match self { Self::HttpForbidden => "http_forbidden", Self::HttpNotFound => "http_not_found", Self::HttpClientError => "http_client_error", Self::HttpTooManyRequests => "http_too_many_requests", Self::HttpServerError => "http_server_error", Self::HttpUnknown => "http_unknown", Self::Dns => "dns", Self::Connect => "connect", Self::Timeout => "timeout", Self::Tls => "tls", Self::Interrupted => "interrupted", Self::Network => "network", Self::Proxy => "proxy", Self::ProcessFailed => "process_failed", Self::Unknown => "unknown", } } pub(crate) fn is_retryable(&self) -> bool { match self { // 代理故障(认证失败、代理主机解析/连接失败)多为配置错误,per-URL 层 // 不重试以免空转;daemon 的 error-retry 循环仍会在下一轮整体重试,覆盖 // 代理短暂抖动。 Self::HttpForbidden | Self::HttpNotFound | Self::HttpClientError | Self::Proxy | Self::ProcessFailed => false, Self::HttpTooManyRequests | Self::HttpServerError | Self::HttpUnknown | Self::Dns | Self::Connect | Self::Timeout | Self::Tls | Self::Interrupted | Self::Network | Self::Unknown => true, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CurlFailure { pub(crate) url: String, pub(crate) destination: Option, pub(crate) exit_code: Option, pub(crate) stderr: String, pub(crate) http_status: Option, pub(crate) kind: CurlFailureKind, } impl CurlFailure { fn from_output(url: &str, destination: Option<&Path>, output: &Output) -> Self { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let exit_code = output.status.code(); let http_status = parse_http_status(&stderr); let kind = classify_failure(exit_code, http_status, &stderr); Self { url: url.to_string(), destination: destination.map(Path::to_path_buf), exit_code, stderr, http_status, kind, } } fn process_failed( url: &str, destination: Option<&Path>, curl_command: &Path, error: std::io::Error, ) -> Self { Self { url: url.to_string(), destination: destination.map(Path::to_path_buf), exit_code: None, stderr: format!("启动 curl 命令失败 {}:{error}", curl_command.display()), http_status: None, kind: CurlFailureKind::ProcessFailed, } } pub(crate) fn retryable(&self) -> bool { self.kind.is_retryable() } fn format_compact(&self) -> String { let mut parts = vec![ format!("kind={}", self.kind.as_str()), format!("retryable={}", self.retryable()), format!("url={}", self.url), ]; if let Some(status) = self.http_status { parts.push(format!("http_status={status}")); } if let Some(code) = self.exit_code { parts.push(format!("curl_exit={code}")); } if let Some(destination) = self.destination.as_ref() { parts.push(format!("destination={}", destination.display())); } if !self.stderr.is_empty() { parts.push(format!("stderr={}", self.stderr)); } parts.join(" ") } } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CurlAttemptFailure { pub(crate) attempt: usize, pub(crate) attempts: usize, pub(crate) failure: CurlFailure, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CurlRetryError { pub(crate) attempts: Vec, } impl CurlRetryError { pub(crate) fn final_failure(&self) -> Option<&CurlFailure> { self.attempts.last().map(|attempt| &attempt.failure) } pub(crate) fn attempt_count(&self) -> usize { self.attempts.len() } pub(crate) fn final_kind_label(&self) -> Option<&'static str> { self.final_failure().map(|failure| failure.kind.as_str()) } pub(crate) fn final_http_status(&self) -> Option { self.final_failure().and_then(|failure| failure.http_status) } pub(crate) fn final_retryable(&self) -> Option { self.final_failure().map(CurlFailure::retryable) } pub(crate) fn summary(&self) -> String { let Some(final_failure) = self.final_failure() else { return "curl 未执行就已失败".to_string(); }; let policy = if final_failure.retryable() { "重试次数已耗尽" } else { "不可重试,已提前停止" }; let attempts = self .attempts .iter() .map(|attempt| { format!( "第 {}/{} 次尝试失败:{}", attempt.attempt, attempt.attempts, attempt.failure.format_compact() ) }) .collect::>() .join(";"); format!("{policy};{attempts}") } } impl std::fmt::Display for CurlRetryError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(&self.summary()) } } impl std::error::Error for CurlRetryError {} pub(crate) fn run_curl_with_retry_with_proxy( curl_command: &Path, url: &str, destination: Option<&Path>, attempts: usize, proxy_config: &CurlProxyConfig, mut build_command: impl FnMut() -> Command, ) -> Result { let attempts = attempts.max(1); let mut failures = Vec::new(); let proxy = resolve_curl_proxy(proxy_config); for attempt in 1..=attempts { let mut command = build_command(); apply_curl_proxy(&mut command, &proxy); let failure = match command.output() { Ok(output) if output.status.success() => return Ok(output), Ok(output) => CurlFailure::from_output(url, destination, &output), Err(error) => CurlFailure::process_failed(url, destination, curl_command, error), }; let retryable = failure.retryable(); failures.push(CurlAttemptFailure { attempt, attempts, failure, }); if !retryable { break; } } Err(CurlRetryError { attempts: failures }) } fn apply_curl_proxy(command: &mut Command, proxy: &ResolvedCurlProxy) { if proxy.disabled { remove_proxy_env(command); command.arg("--noproxy").arg("*"); return; } if let Some(url) = proxy.url.as_ref() { // 通过环境变量而非 --proxy 参数把代理传给 curl:代理 URL 可能带凭据, // 放进 argv 会出现在 curl 子进程的 /proc//cmdline(0444,世界可读)。 // 先清除所有继承的代理变量,再设 ALL_PROXY/all_proxy(覆盖 http 与 https, // 大小写两种写法都设,避免 curl 只读其中一种)。no_proxy 列表不含凭据,仍走参数。 remove_proxy_env(command); command.env("ALL_PROXY", url); command.env("all_proxy", url); if let Some(no_proxy) = proxy.no_proxy.as_ref() { command.arg("--noproxy").arg(no_proxy); } } } fn remove_proxy_env(command: &mut Command) { for name in PROXY_ENV_PRIORITY .iter() .chain(NO_PROXY_ENV_PRIORITY.iter()) { command.env_remove(name); } } /// 从 curl stderr 判断是否为代理自身故障(而非源站故障)。 /// /// 覆盖:无法解析代理(curl exit 5)、CONNECT 隧道被代理拒绝或返回 4xx(如 407 /// 认证失败)等。curl 会把这些原因写进 stderr,比仅凭 exit code 更可靠地区分 /// 代理故障与源站故障。 fn is_proxy_failure(stderr: &str) -> bool { let lower = stderr.to_ascii_lowercase(); lower.contains("resolve proxy") || lower.contains("connect tunnel failed") || lower.contains("from proxy after connect") || (lower.contains("proxy") && lower.contains("407")) } fn classify_failure( exit_code: Option, http_status: Option, stderr: &str, ) -> CurlFailureKind { // 代理故障优先识别:这些错误来自代理链路而非官方源站,单独分类并 fail-fast。 if is_proxy_failure(stderr) { return CurlFailureKind::Proxy; } if exit_code == Some(22) { return match http_status { Some(403) => CurlFailureKind::HttpForbidden, Some(404) => CurlFailureKind::HttpNotFound, Some(408 | 425 | 429) => CurlFailureKind::HttpTooManyRequests, Some(400..=499) => CurlFailureKind::HttpClientError, Some(500..=599) => CurlFailureKind::HttpServerError, Some(_) => CurlFailureKind::HttpUnknown, None => CurlFailureKind::HttpUnknown, }; } match exit_code { Some(5 | 6) => CurlFailureKind::Dns, Some(7) => CurlFailureKind::Connect, Some(18) => CurlFailureKind::Interrupted, Some(28) => CurlFailureKind::Timeout, Some(35) => CurlFailureKind::Tls, Some(52 | 56) => CurlFailureKind::Network, Some(_) => CurlFailureKind::Unknown, None => CurlFailureKind::ProcessFailed, } } fn parse_http_status(stderr: &str) -> Option { let bytes = stderr.as_bytes(); let mut index = 0usize; while index < bytes.len() { if !bytes[index].is_ascii_digit() { index += 1; continue; } let start = index; while index < bytes.len() && bytes[index].is_ascii_digit() { index += 1; } if index - start == 3 { let status = stderr[start..index].parse::().ok()?; if (100..=599).contains(&status) { return Some(status); } } } None } #[cfg(test)] mod tests { use super::*; use std::os::unix::process::ExitStatusExt; #[test] fn classifies_http_status_codes_from_curl_stderr() { let status = std::process::ExitStatus::from_raw(22 << 8); let output = Output { status, stdout: Vec::new(), stderr: b"curl: (22) The requested URL returned error: 404".to_vec(), }; let failure = CurlFailure::from_output("https://example.test/a", None, &output); assert_eq!(failure.http_status, Some(404)); assert_eq!(failure.kind, CurlFailureKind::HttpNotFound); assert!(!failure.retryable()); } #[test] fn treats_5xx_as_retryable() { let status = std::process::ExitStatus::from_raw(22 << 8); let output = Output { status, stdout: Vec::new(), stderr: b"curl: (22) The requested URL returned error: 503".to_vec(), }; let failure = CurlFailure::from_output("https://example.test/a", None, &output); assert_eq!(failure.http_status, Some(503)); assert_eq!(failure.kind, CurlFailureKind::HttpServerError); assert!(failure.retryable()); } #[test] fn classifies_proxy_auth_and_resolution_failures() { // 代理 407 认证失败:CONNECT 隧道失败,识别为 Proxy 且不重试。 let auth = Output { status: std::process::ExitStatus::from_raw(56 << 8), stdout: Vec::new(), stderr: b"curl: (56) CONNECT tunnel failed, response 407".to_vec(), }; let failure = CurlFailure::from_output("https://example.test/a", None, &auth); assert_eq!(failure.kind, CurlFailureKind::Proxy); assert!(!failure.retryable()); // 无法解析代理主机(curl exit 5):同样归为 Proxy 而非普通 DNS。 let resolve = Output { status: std::process::ExitStatus::from_raw(5 << 8), stdout: Vec::new(), stderr: b"curl: (5) Could not resolve proxy: proxy.invalid".to_vec(), }; let failure = CurlFailure::from_output("https://example.test/a", None, &resolve); assert_eq!(failure.kind, CurlFailureKind::Proxy); assert!(!failure.retryable()); // 源站普通连接失败(无代理标记)仍归为 Connect 且可重试。 let origin = Output { status: std::process::ExitStatus::from_raw(7 << 8), stdout: Vec::new(), stderr: b"curl: (7) Failed to connect to example.test port 443".to_vec(), }; let failure = CurlFailure::from_output("https://example.test/a", None, &origin); assert_eq!(failure.kind, CurlFailureKind::Connect); assert!(failure.retryable()); } #[test] fn auto_proxy_prefers_https_and_preserves_no_proxy() { let pairs = vec![ ( "HTTP_PROXY".to_string(), "http://proxy-http:8080".to_string(), ), ( "HTTPS_PROXY".to_string(), "http://proxy-https:7890".to_string(), ), ("NO_PROXY".to_string(), "localhost,127.0.0.1".to_string()), ]; let resolved = resolve_curl_proxy_from_pairs(&CurlProxyConfig::auto(), &pairs); assert!(resolved.enabled); assert!(!resolved.disabled); assert_eq!(resolved.url.as_deref(), Some("http://proxy-https:7890")); assert_eq!(resolved.source, "HTTPS_PROXY"); assert_eq!(resolved.no_proxy.as_deref(), Some("localhost,127.0.0.1")); } #[test] fn disabled_proxy_ignores_environment() { let pairs = vec![( "HTTPS_PROXY".to_string(), "http://proxy-https:7890".to_string(), )]; let resolved = resolve_curl_proxy_from_pairs(&CurlProxyConfig::disabled(), &pairs); assert!(!resolved.enabled); assert!(resolved.disabled); assert!(resolved.url.is_none()); assert_eq!(resolved.source, "--no-proxy"); } #[test] fn explicit_proxy_wins_and_redacts_credentials() { let pairs = vec![( "HTTPS_PROXY".to_string(), "http://environment:7890".to_string(), )]; let resolved = resolve_curl_proxy_from_pairs( &CurlProxyConfig::url("http://user:secret@127.0.0.1:7890"), &pairs, ); assert!(resolved.enabled); assert_eq!( resolved.url.as_deref(), Some("http://user:secret@127.0.0.1:7890") ); assert_eq!(resolved.source, "--proxy"); assert_eq!( redact_proxy_url(resolved.url.as_deref().unwrap()), "http://@127.0.0.1:7890" ); } #[test] fn redact_proxy_url_handles_schemeless_credentials() { assert_eq!( redact_proxy_url("user:secret@127.0.0.1:7890"), "@127.0.0.1:7890" ); assert_eq!( redact_proxy_url("socks5://user:secret@127.0.0.1:1080"), "socks5://@127.0.0.1:1080" ); assert_eq!(redact_proxy_url("127.0.0.1:7890"), "127.0.0.1:7890"); assert_eq!( redact_proxy_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890" ); } fn command_args(command: &Command) -> Vec { command .get_args() .map(|arg| arg.to_string_lossy().to_string()) .collect() } fn command_env(command: &Command, key: &str) -> Option> { command .get_envs() .find(|(name, _)| *name == key) .map(|(_, value)| value.map(|value| value.to_string_lossy().to_string())) } #[test] fn apply_curl_proxy_passes_url_via_env_not_argv() { let proxy = ResolvedCurlProxy { enabled: true, disabled: false, url: Some("http://user:secret@127.0.0.1:7897".to_string()), source: "--proxy".to_string(), no_proxy: Some("localhost,127.0.0.1".to_string()), }; let mut command = Command::new("curl"); apply_curl_proxy(&mut command, &proxy); // 凭据不得出现在 curl 的 argv。 let args = command_args(&command); assert!(!args.iter().any(|arg| arg.contains("secret"))); assert!(!args.iter().any(|arg| arg == "--proxy")); // no_proxy 列表不含凭据,仍以参数传入。 assert!(args .windows(2) .any(|pair| pair == ["--noproxy", "localhost,127.0.0.1"])); // 代理 URL 经 ALL_PROXY/all_proxy 环境变量下传。 assert_eq!( command_env(&command, "ALL_PROXY"), Some(Some("http://user:secret@127.0.0.1:7897".to_string())) ); assert_eq!( command_env(&command, "all_proxy"), Some(Some("http://user:secret@127.0.0.1:7897".to_string())) ); // 继承的 scheme 专用代理变量被清除,避免覆盖 ALL_PROXY。 assert_eq!(command_env(&command, "HTTPS_PROXY"), Some(None)); assert_eq!(command_env(&command, "https_proxy"), Some(None)); } #[test] fn apply_curl_proxy_disabled_forces_direct() { let proxy = ResolvedCurlProxy { enabled: false, disabled: true, url: None, source: "--no-proxy".to_string(), no_proxy: None, }; let mut command = Command::new("curl"); apply_curl_proxy(&mut command, &proxy); let args = command_args(&command); assert!(args.windows(2).any(|pair| pair == ["--noproxy", "*"])); // 直连模式清除所有继承的代理变量,且不设置任何代理。 assert_eq!(command_env(&command, "ALL_PROXY"), Some(None)); assert_eq!(command_env(&command, "HTTPS_PROXY"), Some(None)); } }