Files
BlueArchiveToolkit/infrastructure/src/curl_transfer.rs
T
nyaKazuhaandClaude Fable 5 b570f37359 fix(official-sync): 区分代理故障并前置校验代理配置
- 代理故障分类: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>
2026-07-16 08:18:37 -07:00

739 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>/cmdline0444,世界可读)。
// 先清除所有继承的代理变量,再设 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));
}
}