//! `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 { 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() ); } }