Files
BlueArchiveToolkit/infrastructure/src/curl_transfer.rs
T

882 lines
30 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,
/// 启动 curl 进程时命中 `ETXTBSY`(可执行文件忙)。这是多线程程序里
/// fork/exec 与"刚写出的可执行文件"之间的瞬时竞态:另一线程 fork 出的
/// 子进程在 fork 与 execve 之间短暂持有该文件的可写句柄,导致本次 exec
/// 被内核判为忙。属瞬时错误,可重试。
ProcessBusy,
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::ProcessBusy => "process_busy",
Self::Unknown => "unknown",
}
}
/// 映射到统一错误码(网络域 3xx;代理故障 310001)。
pub(crate) fn error_code(&self) -> bat_core::ErrorCode {
use bat_core::ErrorCode;
match self {
Self::HttpForbidden => ErrorCode::HTTP_FORBIDDEN,
Self::HttpNotFound => ErrorCode::HTTP_NOT_FOUND,
Self::HttpClientError => ErrorCode::HTTP_CLIENT_ERROR,
Self::HttpTooManyRequests => ErrorCode::HTTP_TOO_MANY_REQUESTS,
Self::HttpServerError => ErrorCode::HTTP_SERVER_ERROR,
Self::Dns => ErrorCode::NETWORK_DNS,
Self::Connect => ErrorCode::NETWORK_CONNECT,
Self::Timeout => ErrorCode::NETWORK_TIMEOUT,
Self::Tls => ErrorCode::NETWORK_TLS,
Self::Interrupted => ErrorCode::NETWORK_INTERRUPTED,
Self::Proxy => ErrorCode::PROXY_FAILURE,
Self::HttpUnknown | Self::Network | Self::Unknown => ErrorCode::NETWORK_OTHER,
Self::ProcessFailed | Self::ProcessBusy => ErrorCode::INTERNAL,
}
}
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::ProcessBusy
| 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 {
// ETXTBSY(可执行文件忙)是 fork/exec 的瞬时竞态,可重试;其余启动失败
// (如命令不存在、权限不足)是确定性错误,不重试。
let kind = if error.kind() == std::io::ErrorKind::ExecutableFileBusy {
CurlFailureKind::ProcessBusy
} else {
CurlFailureKind::ProcessFailed
};
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,
}
}
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_error_code(&self) -> Option<bat_core::ErrorCode> {
self.final_failure()
.map(|failure| failure.kind.error_code())
}
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 {}
/// 网络类可重试失败的退避基值(毫秒)。生产 200ms、指数增长;测试下为 0
/// 以免拖慢单测(单测仍验证退避时长的计算,只是不真正 sleep)。
#[cfg(not(test))]
const RETRY_BACKOFF_BASE_MS: u64 = 200;
#[cfg(test)]
const RETRY_BACKOFF_BASE_MS: u64 = 0;
/// 退避上限(毫秒)。
const RETRY_BACKOFF_MAX_MS: u64 = 5_000;
/// 计算第 `attempt` 次失败后、下次重试前的退避时长。
fn retry_backoff(busy: bool, attempt: usize) -> std::time::Duration {
std::time::Duration::from_millis(backoff_delay_ms(RETRY_BACKOFF_BASE_MS, busy, attempt))
}
/// 退避时长(毫秒)的纯计算,便于独立于 cfg 门控的基值做单测。
///
/// - `ETXTBSY`fork/exec 竞态):极短固定退避,只为让兄弟进程完成 execve。
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),避免
/// 连续失败后立即重发。
fn backoff_delay_ms(base: u64, busy: bool, attempt: usize) -> u64 {
if busy {
return 5 * attempt as u64;
}
let shift = attempt.saturating_sub(1).min(5) as u32;
base.saturating_mul(1u64 << shift).min(RETRY_BACKOFF_MAX_MS)
}
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();
let busy = failure.kind == CurlFailureKind::ProcessBusy;
failures.push(CurlAttemptFailure {
attempt,
attempts,
failure,
});
if !retryable {
break;
}
if attempt < attempts {
std::thread::sleep(retry_backoff(busy, attempt));
}
}
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> {
// curl 的 HTTP 状态错误形如 "curl: (22) The requested URL returned error: 404"。
// 锚定到最后一个 "error" 之后再取三位状态码,避免误取 stderr 中其它三位数字
// (如字节数、IP 片段)而错判 HTTP 状态。
let anchor = stderr.rfind("error")?;
let tail = &stderr[anchor..];
let bytes = tail.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 {
if let Ok(status) = tail[start..index].parse::<u16>() {
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 failure_kind_maps_to_error_code() {
use bat_core::ErrorCode;
assert_eq!(
CurlFailureKind::HttpForbidden.error_code().id(),
ErrorCode::HTTP_FORBIDDEN.id()
);
assert_eq!(
CurlFailureKind::HttpNotFound.error_code().id(),
"BAT-ERR-300002"
);
assert_eq!(CurlFailureKind::Proxy.error_code().id(), "BAT-ERR-310001");
assert_eq!(CurlFailureKind::Timeout.error_code().id(), "BAT-ERR-300012");
assert_eq!(CurlFailureKind::Network.error_code().id(), "BAT-ERR-300015");
}
#[test]
fn etxtbsy_spawn_failure_is_retryable() {
// ETXTBSY(可执行文件忙)来自 fork/exec 竞态,应归为可重试的 ProcessBusy
// 其余启动失败(如命令不存在)保持不可重试的 ProcessFailed。
let busy = std::io::Error::from(std::io::ErrorKind::ExecutableFileBusy);
let failure = CurlFailure::process_failed(
"https://example.test/a",
None,
Path::new("/tmp/fake-curl"),
busy,
);
assert_eq!(failure.kind, CurlFailureKind::ProcessBusy);
assert!(failure.retryable());
let missing = std::io::Error::from(std::io::ErrorKind::NotFound);
let failure = CurlFailure::process_failed(
"https://example.test/a",
None,
Path::new("/tmp/fake-curl"),
missing,
);
assert_eq!(failure.kind, CurlFailureKind::ProcessFailed);
assert!(!failure.retryable());
}
#[test]
fn parse_http_status_anchors_on_curl_error_context() {
// 状态码前出现无关三位数字(如端口 443)时,仍从 "error" 之后取真正的状态码。
assert_eq!(
parse_http_status("curl: (22) URL https://host:443/a returned error: 503"),
Some(503)
);
// stderr 中不含 "error" 上下文时不猜测状态码。
assert_eq!(parse_http_status("connected to host 200 ok"), None);
}
#[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));
}
#[test]
fn backoff_is_exponential_capped_and_busy_is_short() {
// 网络类失败:base·2^(attempt-1),上限 5s。
assert_eq!(backoff_delay_ms(200, false, 1), 200);
assert_eq!(backoff_delay_ms(200, false, 2), 400);
assert_eq!(backoff_delay_ms(200, false, 3), 800);
assert_eq!(backoff_delay_ms(200, false, 4), 1600);
// 高次方触顶 5s 上限。
assert_eq!(backoff_delay_ms(200, false, 10), 5000);
// ETXTBSY 走极短固定退避,与指数无关。
assert_eq!(backoff_delay_ms(200, true, 1), 5);
assert_eq!(backoff_delay_ms(200, true, 3), 15);
}
}