diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 9522a5e..89c2746 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -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 { let normalized = value.trim(); if normalized.is_empty() { @@ -4134,7 +4168,10 @@ fn parse_proxy_config(value: &str) -> anyhow::Result { 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] diff --git a/infrastructure/src/curl_transfer.rs b/infrastructure/src/curl_transfer.rs index ab2920b..49f0e58 100644 --- a/infrastructure/src/curl_transfer.rs +++ b/infrastructure/src/curl_transfer.rs @@ -202,6 +202,8 @@ pub(crate) enum CurlFailureKind { Tls, Interrupted, Network, + /// 代理自身故障:无法解析/连接代理,或代理返回 407 认证失败等。 + Proxy, ProcessFailed, Unknown, } @@ -221,6 +223,7 @@ impl CurlFailureKind { Self::Tls => "tls", Self::Interrupted => "interrupted", Self::Network => "network", + Self::Proxy => "proxy", Self::ProcessFailed => "process_failed", Self::Unknown => "unknown", } @@ -228,9 +231,13 @@ impl CurlFailureKind { 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 @@ -261,7 +268,7 @@ impl CurlFailure { 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); + let kind = classify_failure(exit_code, http_status, &stderr); Self { url: url.to_string(), destination: destination.map(Path::to_path_buf), @@ -445,7 +452,29 @@ fn remove_proxy_env(command: &mut Command) { } } -fn classify_failure(exit_code: Option, http_status: Option) -> 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, + 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, @@ -527,6 +556,39 @@ mod tests { 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![