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:
2026-07-16 08:18:37 -07:00
co-authored by Claude Fable 5
parent f0fbe65429
commit b570f37359
2 changed files with 120 additions and 5 deletions
+56 -3
View File
@@ -2893,10 +2893,20 @@ fn command_check(name: &'static str, command: &Path) -> 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 {
name: "proxy",
ok: true,
message: resolve_curl_proxy(config).human_summary(),
ok,
message,
}
}
@@ -4125,6 +4135,30 @@ fn next_option_value(
.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> {
let normalized = value.trim();
if normalized.is_empty() {
@@ -4134,7 +4168,10 @@ fn parse_proxy_config(value: &str) -> anyhow::Result<CurlProxyConfig> {
match normalized.to_ascii_lowercase().as_str() {
"auto" | "env" => Ok(CurlProxyConfig::auto()),
"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();
assert!(options.proxy_option_explicit);
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]