Files
Unlucky-Launcher/crates/core/src/download/proxy.rs
T
AskaEth d584a2c42e feat(download): 把 download.proxy_mode / download.proxy_url 接进代理三态(M295)
台账 P26:两个键引擎侧零消费者——`http::build_client` 只设 UA / 连接超时 / 重定向 / 默认头,
从不 `.proxy()`。设置页画着三档 chip 与一个地址框,改它对任何一次下载都不生效(`system` 那一
档「碰巧能用」只是因为 reqwest 默认读环境变量,不是这段代码做的)。

- core::download::proxy(新):`ProxySetting::from_raw` 是**唯一**翻译层,三档落到 client 上:
  `none` ⇒ `.no_proxy()`(真直连,连 `HTTP_PROXY` 也不读)/ `system` ⇒ 不调任何代理 API
  (跟随环境变量 = M295 之前的行为)/ `manual` ⇒ `.proxy(Proxy::all(url))`;未知值 ⇒ `none`。
- 两条决定写进模块头:`proxy_url` 在非 manual 档**忽略**(不校验、不进结构,值原样留在文件
  里);`manual` + 空地址 ⇒ `system`(设置页那行文案一直这么承诺)。
- 非法地址不穿透:`Proxy::all` 解析失败 ⇒ 有界错误(`InvalidRequest` + 带原串的文案),不
  panic;`DownloadEngine::new` 回落**直连**重建(不用 `reqwest::Client::new()`——那会跟随环境
  变量代理,回落就不确定),原因留在 `proxy_problem()`,CLI 的 `download::engine` 打一行
  stderr;`config().proxy` 不撒谎(仍是用户写的 `manual`)。
- `EngineConfig.proxy` 默认 `System`(=「没读设置」的档:auth 登录 / JDK 下载页 / 地区探测都
  直接用它,保持老行为),`DownloadTuning.proxy` 默认 `Direct`(与 schema 的 `none` 一致)。
- 两键进 `RawTuning`(与 `gh_proxy` 同形),presenter 的 `tuning_of` 与 CLI 的 `tuning` 各加
  两行;两个宿主都是完整字段字面量 ⇒ 「忘了读」是编译错误。`summary()` 加 `代理=…` 一档。
- 判据是「请求真的经过代理」:新夹具 `FakeProxy`(本地 TcpListener)+ 目标 `.invalid`(解析不
  了)⇒ 拿到正文只可能来自代理,且代理记到的请求行是**绝对 URI**。三条用例:manual 真走代理
  / direct 无视环境变量而 system 跟随(同一份 `HTTP_PROXY`)/ 非法地址回落直连不 panic。
  变异 M17-M22 记在 docs/download-settings.md §6。
- 行为变化(doc §5.2 写明):`none` 从「碰巧跟随环境变量」变成真直连,靠 `HTTP_PROXY` 上网的
  用户要选一次 `system`。发布版 GUI 的「下载 JDK」页仍不读这两个键(finding 已开)。
- 门禁:四 crate `check --all-targets` / 测试(3201 passed, 0 failed)/ `clippy -D warnings` /
  `fmt --check`;另跑了 `check --workspace --all-targets`。判据用 `$?` 与 `test result:` 行验。
2026-11-13 10:43:09 +08:00

295 lines
15 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.
//! `download.proxy_mode` / `download.proxy_url` → reqwest 客户端的代理三态(M295)。
//!
//! # 现状(M295 之前)
//!
//! `grep -rn "proxy_mode\|proxy_url\|ProxyMode" crates/core/src --include=*.rs | grep -v setting/keys`
//! **零命中**:两个键在引擎侧零消费者。`http::build_client` 当时只设 UA / 连接超时 / 重定向 /
//! 默认头,**从不 `.proxy()`**,于是设置页画着的「直连 / 系统代理 / 手动代理」三档 chip 与那个
//! 地址文本框,对任何一次下载都不生效。`system` 那一档「碰巧能用」只是因为 reqwest 自己会读进程
//! 环境变量(`HTTP_PROXY` 等),不是这段代码做的——这也是本模块必须把它写成**显式一档**的理由:
//! 三档都要是这段代码的判决,而不是「碰巧」。
//!
//! # 三档语义(全仓唯一一处翻译)
//!
//! | `download.proxy_mode` | 翻译成 | 落到 client 上 | 含义 |
//! |---|---|---|---|
//! | `none`(schema 出厂值,键不存在也走这档) | [`ProxySetting::Direct`] | `.no_proxy()` | 什么都不走:连进程环境里的 `HTTP_PROXY` 也不读 |
//! | `system` | [`ProxySetting::System`] | **不调任何代理 API** | 跟随进程环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`)= reqwest 的默认行为,也是 M295 之前所有下载的实际行为 |
//! | `manual` | [`ProxySetting::Manual`] | `.proxy(reqwest::Proxy::all(url))` | 整条流量走 `proxy_url`(http 与 https 目标都经它;`Proxy::all` 会顺手关掉「自动读系统代理」) |
//!
//! 未知值 / 空串 ⇒ `none`(与 schema 出厂值一致:静默回落、不报错、不中断下载,口径与
//! `preference` 那两个枚举键相同)。
//!
//! **默认值为什么要分两处**(`EngineConfig::default()` 是 `System`、`DownloadTuning::default()`
//! 是 `Direct`):`EngineConfig::default()` 是「这个子系统**没读**设置」的档(`auth` 的登录、
//! 发布版 GUI 的 JDK 下载页、地区探测都直接用它),保持 M295 之前的行为(跟随环境变量)才不算
//! 静默改网络路径;而读了设置的装配点(`shared::build` / `engine_with`)一律由
//! `DownloadTuning` 显式盖上去,那一路的出厂值是 `none` —— 与设置页画的那颗 chip 一致,
//! 「界面说 none、实际偷偷走系统代理」正是本任务要治的病。
//!
//! ## `proxy_url` 在非 manual 模式下:**忽略**
//!
//! 一句话决定:`proxy_url` 只在 `manual` 档被读;`none` / `system` 下它**既不校验、也不进入**
//! [`ProxySetting`],值原样留在 `launcher.json` 里(模式切回 `manual` 就立刻生效)。模式是唯一
//! 开关,所以不存在「填了地址、模式没选对、半生效」这种中间态。
//!
//! `manual` + **空地址** ⇒ [`ProxySetting::System`]:设置页那行文案承诺过「manual 模式生效;留空则
//! 用系统代理」,这里按文案兑现。不选 `Direct` —— 那会让「留空」变成第三种没人写过的语义。
//!
//! # 非法地址不得穿透
//!
//! `proxy_url` 写成解析不了的串时,[`ProxySetting::apply`] 返回**有界错误**
//! ([`DownloadErrorKind::InvalidRequest`](super::error::DownloadErrorKind::InvalidRequest) +
//! 带原串的可读文案),**不 panic**。装配点([`super::engine::DownloadEngine::new`])的处置是
//! 「回落直连 + 把原因留在引擎上」(`DownloadEngine::proxy_problem`):一个设置字符串不该让用户
//! 连文件都下不了,但也不许它悄悄变成「用了某个不知道是什么的代理」。
//!
//! 一条实测(别把它当 bug 修掉):reqwest 的 `Proxy::all` 对**没有 scheme** 的串会自作主张补
//! `http://` —— `127.0.0.1:7890` 是合法写法。所以用户少写 `http://` 不算「解析不了」;真正会失败的
//! 是端口越界(`…:99999`)、方括号不闭合这一类。
//!
//! # 不落盘
//!
//! 与 `preference` 同一条纪律:翻译只发生在装配那一刻,`launcher.json` 里存的永远是用户写的原文。
//!
//! # 没验的部分
//!
//! 真机上的「系统代理」在 Windows/macOS 上的注册表/偏好设置路径**没有**被读(本仓 reqwest 没开
//! `client-proxy-system` feature ⇒ `system` 只认环境变量),PAC 脚本与需要认证(`Proxy-Authorization`)
//! 的代理同样没测。
use reqwest::ClientBuilder;
use super::error::{DownloadError, DownloadErrorKind};
/// `download.proxy_mode` + `download.proxy_url` 的翻译结论(**全仓唯一**一份)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ProxySetting {
/// `none`(schema 出厂值):**真直连**,连环境变量里的代理也不读。
#[default]
Direct,
/// `system`:跟随进程环境变量(reqwest 的默认行为,本档不调任何代理 API)。
System,
/// `manual`:整条流量走这个地址。
Manual {
/// `download.proxy_url` 的原串(已去前后空白;**未**校验,校验在 [`Self::apply`])。
url: String,
},
}
impl ProxySetting {
/// 设置原文 → 代理三态(**唯一**翻译入口:两个键一起读,规则见模块头)。
///
/// `mode` / `url` 的 `None` 是「键不存在 / 类型不对」(宿主侧 `effective_str` 的两种失败),
/// 与空串一样按「没填」处理:`mode` 没填 ⇒ `none`,`url` 没填 + `manual` ⇒ `system`。
#[must_use]
pub fn from_raw(mode: Option<&str>, url: Option<&str>) -> Self {
let url = url.map(str::trim).unwrap_or_default();
match mode
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"system" => Self::System,
"manual" if url.is_empty() => Self::System,
"manual" => Self::Manual {
url: url.to_string(),
},
// `none` / 空串 / 未知值(手改文件写出来的 `mars`)都落这一档:与 schema 出厂值一致。
_ => Self::Direct,
}
}
/// 稳定字符串 id(与 schema 的取值逐字相同;日志/诊断用)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Direct => "none",
Self::System => "system",
Self::Manual { .. } => "manual",
}
}
/// 一行「引擎实际拿到了什么」(`DownloadTuning::summary` 用:三档语义不同,只印 `as_str`
/// 会把「跟随环境变量」与「某个具体地址」印成同一句话)。
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Direct => "none(直连)".to_string(),
Self::System => "system(跟随环境变量)".to_string(),
Self::Manual { url } => format!("manual({url})"),
}
}
/// 把这一档盖到 client builder 上(**唯一**应用点:`http::build_client`)。
///
/// # Errors
///
/// 只在 `manual` 档、且地址解析不出来时:`kind = InvalidRequest`,`message` 带上那个原串
/// (用户要能照着改)+ reqwest 的解析原因。**不 panic、不静默改成别的档**——回落由装配点决定,
/// 见模块头「非法地址不得穿透」。
pub fn apply(&self, builder: ClientBuilder) -> Result<ClientBuilder, DownloadError> {
match self {
Self::Direct => Ok(builder.no_proxy()),
// 故意的空操作:reqwest 的默认行为就是「有显式代理用显式的,没有就读环境变量」,
// 这一档要的正是后者。写成 `.proxy(Proxy::system())` 会更像「显式」,但 reqwest 0.12
// 没有这个公开构造函数(`system` 只在 `auto_sys_proxy` 为真时内部生效)。
Self::System => Ok(builder),
Self::Manual { url } => {
let proxy = reqwest::Proxy::all(url).map_err(|err| {
DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!("代理地址无法解析({url}):{err}"),
)
})?;
Ok(builder.proxy(proxy))
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::config::{DownloadTuning, EngineConfig};
use super::super::error::DownloadErrorKind;
use super::ProxySetting;
/// **两键 → 三档**的唯一翻译规则(表驱动,逐档一条)。
///
/// 抓得住:① `manual` / `system` 拼错或大小写敏感 ⇒ 对应档红;② 非 manual 档还在读 `proxy_url`
/// (`none` + 一个地址仍进 `Manual`)⇒ 第 2、3、4 条红;③ `manual` + 空地址落成 `Direct`
/// (而不是设置页承诺的「用系统代理」)⇒ 第 5、6 条红;④ 地址没 trim、模式没小写归一 ⇒ 第 8 条红。
/// 抓不住:这些档在真客户端上的行为(那是 `tests/download/engine_mock/proxy.rs` 的活)。
#[test]
fn the_two_keys_translate_to_the_three_modes() {
let cases: [(Option<&str>, Option<&str>, ProxySetting); 8] = [
// 键不存在 / 空串 ⇒ none(schema 出厂值)。
(None, None, ProxySetting::Direct),
(
Some(""),
Some("http://127.0.0.1:7890"),
ProxySetting::Direct,
),
// 显式 none:地址**忽略**(不进结构)。
(
Some("none"),
Some("http://127.0.0.1:7890"),
ProxySetting::Direct,
),
// system:地址同样忽略。
(
Some("system"),
Some("http://127.0.0.1:7890"),
ProxySetting::System,
),
// manual + 空地址 ⇒ 用系统代理(设置页那行文案的承诺)。
(Some("manual"), Some(" "), ProxySetting::System),
(Some("manual"), None, ProxySetting::System),
// 未知值(手改文件)⇒ 回落 none。
(Some("mars"), None, ProxySetting::Direct),
// 正常档:大小写/空白不敏感,地址去空白后原样保留。
(
Some(" MANUAL "),
Some(" http://127.0.0.1:7890 "),
ProxySetting::Manual {
url: "http://127.0.0.1:7890".to_string(),
},
),
];
for (mode, url, expected) in cases {
assert_eq!(
ProxySetting::from_raw(mode, url),
expected,
"mode={mode:?} url={url:?}"
);
}
// `as_str` 与 schema 的三个取值逐字相同(诊断行/日志里印的就是它)。
assert_eq!(ProxySetting::Direct.as_str(), "none");
assert_eq!(ProxySetting::System.as_str(), "system");
assert_eq!(
ProxySetting::Manual {
url: "http://p:1".to_string()
}
.as_str(),
"manual"
);
}
/// **非法地址 = 有界错误,不是 panic**(任务书第 4 条)。
///
/// 断言的是 `apply()` 的返回,而不是「调用方记得检查」:装配点 `build_client` 直接 `?` 它。
/// 抓得住:把 `Proxy::all(url)?` 写成 `expect` ⇒ 本用例 panic(红);把解析失败吞成 `Ok` ⇒
/// `Err` 那条红(用户会以为「用了代理」,实际谁也不知道用了什么)。
/// 抓不住:真机上这个错串好不好懂(那是文案评审的事)。
///
/// 地址取**端口越界**那个:`reqwest` 对「没有 scheme」的串会自作主张补 `http://`
/// (`Proxy::all("127.0.0.1:7890")` 是合法的、能解析),所以「解析不了」的样本必须挑
/// 补了前缀也救不回来的那种——端口 99999 超出 1–65535。
#[test]
fn a_broken_proxy_url_is_a_bounded_error_not_a_panic() {
let broken = ProxySetting::Manual {
url: "http://127.0.0.1:99999".to_string(),
};
let error = broken
.apply(reqwest::Client::builder())
.expect_err("解析不了的代理地址必须是有界错误");
assert_eq!(error.kind, DownloadErrorKind::InvalidRequest);
assert!(
error.message.contains("http://127.0.0.1:99999"),
"文案必须带上用户写的那串(否则他改不动):{}",
error.message
);
assert!(
error.message.contains("代理地址无法解析"),
"{}",
error.message
);
// 另外两档正常:`none` 与 `system` 都不该因为「没有地址」而失败。
for ok in [ProxySetting::Direct, ProxySetting::System] {
assert!(
ok.apply(reqwest::Client::builder()).is_ok(),
"{ok:?} 不该失败"
);
}
}
/// **两个默认值故意不同**(模块头那段的守卫):没读设置的子系统保持 M295 之前的行为,
/// 读了设置的装配点与设置页的 `none` chip 对齐。
///
/// 抓得住:有人把 `EngineConfig::default().proxy` 一并改成 `Direct` ⇒ 第一条红(`auth` 与 JDK
/// 下载页会静默失去环境变量代理,那些路径在本仓读不到 `download.*`,改不回来);把它改成
/// `Manual` 之类 ⇒ 第一条也红。`DownloadTuning::default()` 改成 `System` ⇒ 第二条红
/// (设置页画着 `none`,引擎却在跟随环境变量——本任务要治的原病)。
#[test]
fn the_two_defaults_are_deliberately_different() {
assert_eq!(
EngineConfig::default().proxy,
ProxySetting::System,
"EngineConfig::default() 是「没读设置」的档:保持 M295 之前的跟随环境变量"
);
assert_eq!(
DownloadTuning::default().proxy,
ProxySetting::Direct,
"DownloadTuning::default() 是「读了设置、都是出厂值」的档:与 schema 的 none 对齐"
);
// 一行生效值里必须能看出是哪一档(`none` 与 `system` 行为相反,印成同一句话就是新谎)。
let line = DownloadTuning::default().summary();
assert!(line.contains("代理=none(直连)"), "{line}");
let manual = DownloadTuning {
proxy: ProxySetting::Manual {
url: "http://127.0.0.1:7890".to_string(),
},
..DownloadTuning::default()
};
assert!(
manual
.summary()
.contains("代理=manual(http://127.0.0.1:7890)"),
"{}",
manual.summary()
);
}
}