mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
- 代理故障分类:curl 因代理 407 认证失败(CONNECT 隧道失败)、无法解析/连接 代理等,原被归为普通可重试网络错误,永不成功也会耗尽 per-URL 重试且无诊断。 新增 CurlFailureKind::Proxy,从 curl stderr 识别代理故障,per-URL 层 fail-fast 不重试(daemon error-retry 循环仍整体重试以覆盖代理短暂抖动),并在失败摘要里 以 kind=proxy 明确标注。 - 前置校验:parse_proxy_config 新增 validate_proxy_url,拒绝拼错的代理 scheme (如 htp://),只接受 http/https/socks4/socks4a/socks5/socks5h 或无 scheme 的 host:port。 - doctor:proxy 检查不再恒 ok,对解析出的代理 URL(含 auto 从环境变量取得的) 做 scheme 校验,前置暴露配置错误。 新增单测:代理 407/解析失败归为 Proxy 且不重试、源站连接失败仍为 Connect 可重试、 拒绝不支持的 scheme、无 scheme host:port 接受。 对应 issue #18 维护清单 2-5。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
739 lines
24 KiB
Rust
739 lines
24 KiB
Rust
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<String>) -> 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<String>,
|
||
/// Where this proxy decision came from.
|
||
pub source: String,
|
||
/// `NO_PROXY`/`no_proxy` value preserved for `curl`, when present.
|
||
pub no_proxy: Option<String>,
|
||
}
|
||
|
||
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}://<redacted>@{}", &rest[at_index + 1..]);
|
||
}
|
||
let Some(at_index) = url.find('@') else {
|
||
return url.to_string();
|
||
};
|
||
format!("<redacted>@{}", &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<PathBuf>,
|
||
pub(crate) exit_code: Option<i32>,
|
||
pub(crate) stderr: String,
|
||
pub(crate) http_status: Option<u16>,
|
||
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<CurlAttemptFailure>,
|
||
}
|
||
|
||
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<u16> {
|
||
self.final_failure().and_then(|failure| failure.http_status)
|
||
}
|
||
|
||
pub(crate) fn final_retryable(&self) -> Option<bool> {
|
||
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::<Vec<_>>()
|
||
.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<Output, CurlRetryError> {
|
||
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/<pid>/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<i32>,
|
||
http_status: Option<u16>,
|
||
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<u16> {
|
||
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::<u16>().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://<redacted>@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"),
|
||
"<redacted>@127.0.0.1:7890"
|
||
);
|
||
assert_eq!(
|
||
redact_proxy_url("socks5://user:secret@127.0.0.1:1080"),
|
||
"socks5://<redacted>@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<String> {
|
||
command
|
||
.get_args()
|
||
.map(|arg| arg.to_string_lossy().to_string())
|
||
.collect()
|
||
}
|
||
|
||
fn command_env(command: &Command, key: &str) -> Option<Option<String>> {
|
||
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));
|
||
}
|
||
}
|