Files
BlueArchiveToolkit/infrastructure/src/curl_transfer.rs
T

580 lines
17 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.
pub fn redact_proxy_url(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_string();
};
let Some(at_index) = rest.find('@') else {
return url.to_string();
};
format!("{scheme}://<redacted>@{}", &rest[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,
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::ProcessFailed => "process_failed",
Self::Unknown => "unknown",
}
}
pub(crate) fn is_retryable(&self) -> bool {
match self {
Self::HttpForbidden
| Self::HttpNotFound
| Self::HttpClientError
| 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);
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() {
remove_proxy_env(command);
command.arg("--proxy").arg(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);
}
}
fn classify_failure(exit_code: Option<i32>, http_status: Option<u16>) -> CurlFailureKind {
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 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"
);
}
}