mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:15:14 +08:00
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>
This commit is contained in:
@@ -2893,10 +2893,20 @@ fn command_check(name: &'static str, command: &Path) -> DoctorCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn proxy_check(config: &CurlProxyConfig) -> DoctorCheck {
|
fn proxy_check(config: &CurlProxyConfig) -> DoctorCheck {
|
||||||
|
let resolved = resolve_curl_proxy(config);
|
||||||
|
// 对解析出的代理 URL(含 auto 模式从环境变量取得的)做 scheme 校验,
|
||||||
|
// 让 doctor 能在前置阶段暴露拼错的代理配置,而非恒为 ok。
|
||||||
|
let (ok, message) = match resolved.url.as_deref() {
|
||||||
|
Some(url) => match validate_proxy_url(url) {
|
||||||
|
Ok(()) => (true, resolved.human_summary()),
|
||||||
|
Err(error) => (false, format!("{};{error}", resolved.human_summary())),
|
||||||
|
},
|
||||||
|
None => (true, resolved.human_summary()),
|
||||||
|
};
|
||||||
DoctorCheck {
|
DoctorCheck {
|
||||||
name: "proxy",
|
name: "proxy",
|
||||||
ok: true,
|
ok,
|
||||||
message: resolve_curl_proxy(config).human_summary(),
|
message,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4125,6 +4135,30 @@ fn next_option_value(
|
|||||||
.ok_or_else(|| anyhow::anyhow!("{flag} 缺少参数值"))
|
.ok_or_else(|| anyhow::anyhow!("{flag} 缺少参数值"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// curl 支持的代理 scheme。
|
||||||
|
const SUPPORTED_PROXY_SCHEMES: [&str; 6] =
|
||||||
|
["http", "https", "socks4", "socks4a", "socks5", "socks5h"];
|
||||||
|
|
||||||
|
/// 校验显式代理 URL 的 scheme,尽早拒绝拼错的 scheme(如 `htp://`)。
|
||||||
|
///
|
||||||
|
/// 无 `://` 时 curl 默认按 http 处理 `host:port`,此处只要求非空;带 `://` 时
|
||||||
|
/// scheme 必须是 curl 支持的代理协议,且代理主机部分不能为空。
|
||||||
|
fn validate_proxy_url(url: &str) -> anyhow::Result<()> {
|
||||||
|
if let Some((scheme, rest)) = url.split_once("://") {
|
||||||
|
let scheme_lower = scheme.to_ascii_lowercase();
|
||||||
|
if !SUPPORTED_PROXY_SCHEMES.contains(&scheme_lower.as_str()) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"--proxy 使用了不支持的 scheme:{scheme}(支持 {})",
|
||||||
|
SUPPORTED_PROXY_SCHEMES.join("/")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if rest.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("--proxy 缺少代理主机:{url}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_proxy_config(value: &str) -> anyhow::Result<CurlProxyConfig> {
|
fn parse_proxy_config(value: &str) -> anyhow::Result<CurlProxyConfig> {
|
||||||
let normalized = value.trim();
|
let normalized = value.trim();
|
||||||
if normalized.is_empty() {
|
if normalized.is_empty() {
|
||||||
@@ -4134,7 +4168,10 @@ fn parse_proxy_config(value: &str) -> anyhow::Result<CurlProxyConfig> {
|
|||||||
match normalized.to_ascii_lowercase().as_str() {
|
match normalized.to_ascii_lowercase().as_str() {
|
||||||
"auto" | "env" => Ok(CurlProxyConfig::auto()),
|
"auto" | "env" => Ok(CurlProxyConfig::auto()),
|
||||||
"none" | "direct" | "off" | "disabled" => Ok(CurlProxyConfig::disabled()),
|
"none" | "direct" | "off" | "disabled" => Ok(CurlProxyConfig::disabled()),
|
||||||
_ => Ok(CurlProxyConfig::url(normalized.to_string())),
|
_ => {
|
||||||
|
validate_proxy_url(normalized)?;
|
||||||
|
Ok(CurlProxyConfig::url(normalized.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4372,6 +4409,22 @@ mod tests {
|
|||||||
let options = parse(&["bat", "--proxy", "auto"]).unwrap();
|
let options = parse(&["bat", "--proxy", "auto"]).unwrap();
|
||||||
assert!(options.proxy_option_explicit);
|
assert!(options.proxy_option_explicit);
|
||||||
assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Auto);
|
assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Auto);
|
||||||
|
|
||||||
|
// socks5 等受支持 scheme 可用。
|
||||||
|
let options = parse(&["bat", "--proxy", "socks5://127.0.0.1:1080"]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
options.config.curl_proxy.mode(),
|
||||||
|
&CurlProxyMode::Url("socks5://127.0.0.1:1080".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_proxy_with_unsupported_scheme() {
|
||||||
|
let error = parse(&["bat", "--proxy", "htp://127.0.0.1:7890"]).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("不支持的 scheme"));
|
||||||
|
|
||||||
|
// 无 scheme 的 host:port 仍接受(curl 默认按 http 处理)。
|
||||||
|
assert!(parse(&["bat", "--proxy", "127.0.0.1:7890"]).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ pub(crate) enum CurlFailureKind {
|
|||||||
Tls,
|
Tls,
|
||||||
Interrupted,
|
Interrupted,
|
||||||
Network,
|
Network,
|
||||||
|
/// 代理自身故障:无法解析/连接代理,或代理返回 407 认证失败等。
|
||||||
|
Proxy,
|
||||||
ProcessFailed,
|
ProcessFailed,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
@@ -221,6 +223,7 @@ impl CurlFailureKind {
|
|||||||
Self::Tls => "tls",
|
Self::Tls => "tls",
|
||||||
Self::Interrupted => "interrupted",
|
Self::Interrupted => "interrupted",
|
||||||
Self::Network => "network",
|
Self::Network => "network",
|
||||||
|
Self::Proxy => "proxy",
|
||||||
Self::ProcessFailed => "process_failed",
|
Self::ProcessFailed => "process_failed",
|
||||||
Self::Unknown => "unknown",
|
Self::Unknown => "unknown",
|
||||||
}
|
}
|
||||||
@@ -228,9 +231,13 @@ impl CurlFailureKind {
|
|||||||
|
|
||||||
pub(crate) fn is_retryable(&self) -> bool {
|
pub(crate) fn is_retryable(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
|
// 代理故障(认证失败、代理主机解析/连接失败)多为配置错误,per-URL 层
|
||||||
|
// 不重试以免空转;daemon 的 error-retry 循环仍会在下一轮整体重试,覆盖
|
||||||
|
// 代理短暂抖动。
|
||||||
Self::HttpForbidden
|
Self::HttpForbidden
|
||||||
| Self::HttpNotFound
|
| Self::HttpNotFound
|
||||||
| Self::HttpClientError
|
| Self::HttpClientError
|
||||||
|
| Self::Proxy
|
||||||
| Self::ProcessFailed => false,
|
| Self::ProcessFailed => false,
|
||||||
Self::HttpTooManyRequests
|
Self::HttpTooManyRequests
|
||||||
| Self::HttpServerError
|
| Self::HttpServerError
|
||||||
@@ -261,7 +268,7 @@ impl CurlFailure {
|
|||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
let exit_code = output.status.code();
|
let exit_code = output.status.code();
|
||||||
let http_status = parse_http_status(&stderr);
|
let http_status = parse_http_status(&stderr);
|
||||||
let kind = classify_failure(exit_code, http_status);
|
let kind = classify_failure(exit_code, http_status, &stderr);
|
||||||
Self {
|
Self {
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
destination: destination.map(Path::to_path_buf),
|
destination: destination.map(Path::to_path_buf),
|
||||||
@@ -445,7 +452,29 @@ fn remove_proxy_env(command: &mut Command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_failure(exit_code: Option<i32>, http_status: Option<u16>) -> CurlFailureKind {
|
/// 从 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) {
|
if exit_code == Some(22) {
|
||||||
return match http_status {
|
return match http_status {
|
||||||
Some(403) => CurlFailureKind::HttpForbidden,
|
Some(403) => CurlFailureKind::HttpForbidden,
|
||||||
@@ -527,6 +556,39 @@ mod tests {
|
|||||||
assert!(failure.retryable());
|
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]
|
#[test]
|
||||||
fn auto_proxy_prefers_https_and_preserves_no_proxy() {
|
fn auto_proxy_prefers_https_and_preserves_no_proxy() {
|
||||||
let pairs = vec![
|
let pairs = vec![
|
||||||
|
|||||||
Reference in New Issue
Block a user