diff --git a/crates/cli/src/download/tests/proxy.rs b/crates/cli/src/download/tests/proxy.rs index 91324a8..9e34dce 100644 --- a/crates/cli/src/download/tests/proxy.rs +++ b/crates/cli/src/download/tests/proxy.rs @@ -76,3 +76,35 @@ fn the_proxy_settings_reach_the_cli_engine_and_the_console_line() { assert!(schema.spec(key).is_some(), "{key} 没登记"); } } + +/// **口令不进终端**(r1 复审 p2-1 的 CLI 侧判据):`announce` 那一行就是用户(和 CI 日志)看到的 +/// 字符串,所以断言落在**最终的字符串**上,而不是「函数存在」。 +/// +/// 变异:`ProxySetting::describe` 改回 `format!("manual({url})")` ⇒ 第一段红(`contains(SECRET)`)。 +#[test] +fn the_console_line_never_carries_the_proxy_password() { + const SECRET: &str = "PWMUSTNOTLEAK"; + let dir = TempDir::new("proxy-secret"); + let ctx = ctx_at(&dir); + write_launcher_config( + &ctx, + json!({ "download": { "proxy_mode": "manual", "proxy_url": format!("http://user:{SECRET}@127.0.0.1:7890") } }), + ); + let line = + crate::download::announce(&crate::download::tuning(&ctx)).expect("改过设置 ⇒ 该有一行"); + assert!(!line.contains(SECRET), "终端那行泄漏了代理口令:{line}"); + assert!(line.contains("user:***@"), "{line}"); + + // 非法地址那一行(回落提示)同样不许带口令。 + write_launcher_config( + &ctx, + json!({ "download": { "proxy_mode": "manual", "proxy_url": format!("http://user:{SECRET}@127.0.0.1:99999") } }), + ); + let engine = crate::commands::install::build_engine(&ctx); + let problem = engine.proxy_problem().expect("端口越界要留下原因"); + assert!( + !problem.contains(SECRET), + "回落提示泄漏了代理口令:{problem}" + ); + assert!(problem.contains("user:***@"), "{problem}"); +} diff --git a/crates/core/src/download/proxy.rs b/crates/core/src/download/proxy.rs index d13a5ac..4ad586d 100644 --- a/crates/core/src/download/proxy.rs +++ b/crates/core/src/download/proxy.rs @@ -15,7 +15,7 @@ //! |---|---|---|---| //! | `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` 会顺手关掉「自动读系统代理」) | +//! | `manual` | [`ProxySetting::Manual`] | `.proxy(Proxy::all(url))` | 整条流量走 `proxy_url`(http 与 https 目标都经它;`Proxy::all` 会顺手关掉「自动读系统代理」) | //! //! 未知值 / 空串 ⇒ `none`(与 schema 出厂值一致:静默回落、不报错、不中断下载,口径与 //! `preference` 那两个枚举键相同)。 @@ -36,13 +36,26 @@ //! `manual` + **空地址** ⇒ [`ProxySetting::System`]:设置页那行文案承诺过「manual 模式生效;留空则 //! 用系统代理」,这里按文案兑现。不选 `Direct` —— 那会让「留空」变成第三种没人写过的语义。 //! -//! # 非法地址不得穿透 +//! # 口令:**所有**用户可见 / 会落盘的字符串都先脱敏(r1 复审 p2-1) //! -//! `proxy_url` 写成解析不了的串时,[`ProxySetting::apply`] 返回**有界错误** -//! ([`DownloadErrorKind::InvalidRequest`](super::error::DownloadErrorKind::InvalidRequest) + -//! 带原串的可读文案),**不 panic**。装配点([`super::engine::DownloadEngine::new`])的处置是 -//! 「回落直连 + 把原因留在引擎上」(`DownloadEngine::proxy_problem`):一个设置字符串不该让用户 -//! 连文件都下不了,但也不许它悄悄变成「用了某个不知道是什么的代理」。 +//! `proxy_url` 允许带 userinfo(`http://user:password@host:port`),而这个键在本仓就是凭据 +//! (schema 是 `AiPolicy::sensitive()`、`ull config get` 打 ``)。M295 第一版把 +//! `manual` 的原串整条写进 `summary()`(→ CLI stderr)与解析失败文案,等于把口令还原成明文。 +//! 现在**唯一**的出口是 [`redact_proxy_url`]:`user:password@` → `user:***@`(用户名保留,口令打掉; +//! 没有口令的地址**逐字不变**)。[`ProxySetting::describe`] / 错误文案 / `Debug` 都过它, +//! 宿主直接拼 `problem()` 的返回值即可,**不许**自己再拼一遍地址。 +//! +//! # 非法值不得穿透(两类都走同一条路:有界错误 + 回落直连 + 告知) +//! +//! 1. **解析不了**(端口越界 `…:99999`、方括号不闭合…):[`ProxySetting::apply`] 返回 +//! `InvalidRequest` + 带(脱敏后)原串与**能读**的原因,**不 panic**; +//! 2. **解析得了但本构建用不上**(`socks5://` 等:reqwest 没开 `socks` feature,而 +//! `Proxy::all` 对它们照样返回 `Ok`):同样是有界错误,文案直接点名 scheme。 +//! +//! 装配点([`super::engine::DownloadEngine::new`])的处置是「回落直连 + 把原因留在引擎上」 +//! (`DownloadEngine::proxy_problem`):一个设置字符串不该让用户连文件都下不了,但也不许它悄悄变成 +//! 「用了某个不知道是什么的代理」。**回落必须被看见**:CLI 打一行 stderr,GUI 的设置页把 +//! [`ProxySetting::problem`] 的结论画在「HTTP 代理」那一行(两个宿主都调同一个判定,不各写一份)。 //! //! 一条实测(别把它当 bug 修掉):reqwest 的 `Proxy::all` 对**没有 scheme** 的串会自作主张补 //! `http://` —— `127.0.0.1:7890` 是合法写法。所以用户少写 `http://` 不算「解析不了」;真正会失败的 @@ -58,12 +71,18 @@ //! `client-proxy-system` feature ⇒ `system` 只认环境变量),PAC 脚本与需要认证(`Proxy-Authorization`) //! 的代理同样没测。 +use std::fmt; + use reqwest::ClientBuilder; +use reqwest::Url; use super::error::{DownloadError, DownloadErrorKind}; /// `download.proxy_mode` + `download.proxy_url` 的翻译结论(**全仓唯一**一份)。 -#[derive(Debug, Clone, PartialEq, Eq, Default)] +/// +/// `Debug` 手写(不 derive):`Manual` 里的地址会过 [`redact_proxy_url`] —— 引擎 / 配置都会被 +/// `{:?}` 打进日志与诊断,那条路径不该是口令的第二出口。 +#[derive(Clone, PartialEq, Eq, Default)] pub enum ProxySetting { /// `none`(schema 出厂值):**真直连**,连环境变量里的代理也不读。 #[default] @@ -73,10 +92,21 @@ pub enum ProxySetting { /// `manual`:整条流量走这个地址。 Manual { /// `download.proxy_url` 的原串(已去前后空白;**未**校验,校验在 [`Self::apply`])。 + /// **原串留着**:真发请求要用它;一切对外字符串都先过 [`redact_proxy_url`]。 url: String, }, } +impl fmt::Debug for ProxySetting { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Direct => f.write_str("Direct"), + Self::System => f.write_str("System"), + Self::Manual { url } => write!(f, "Manual {{ url: {:?} }}", redact_proxy_url(url)), + } + } +} + impl ProxySetting { /// 设置原文 → 代理三态(**唯一**翻译入口:两个键一起读,规则见模块头)。 /// @@ -113,12 +143,26 @@ impl ProxySetting { /// 一行「引擎实际拿到了什么」(`DownloadTuning::summary` 用:三档语义不同,只印 `as_str` /// 会把「跟随环境变量」与「某个具体地址」印成同一句话)。 + /// + /// 地址过 [`redact_proxy_url`]:这行会进 CLI 的 stderr(`download::announce`)、进而进 CI 日志。 #[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})"), + Self::Manual { url } => format!("manual({})", redact_proxy_url(url)), + } + } + + /// 这一档在当前构建里**能不能真的用上**(`None` = 没问题;`Some(原因)` = 用不了,会回落直连)。 + /// + /// 与 [`Self::apply`] 是**同一个判定**(都走 `manual_proxy`):宿主拿它给用户一句可见的提示 + /// (GUI 设置页那一行 / CLI 那行 stderr),不必为了一个提示真的去造 client。文案已脱敏。 + #[must_use] + pub fn problem(&self) -> Option { + match self { + Self::Manual { url } => manual_proxy(url).err().map(|err| err.message), + Self::Direct | Self::System => None, } } @@ -126,9 +170,8 @@ impl ProxySetting { /// /// # Errors /// - /// 只在 `manual` 档、且地址解析不出来时:`kind = InvalidRequest`,`message` 带上那个原串 - /// (用户要能照着改)+ reqwest 的解析原因。**不 panic、不静默改成别的档**——回落由装配点决定, - /// 见模块头「非法地址不得穿透」。 + /// 只在 `manual` 档、且地址用不了时:`kind = InvalidRequest`,`message` 带上(脱敏后的)原串与 + /// **能读**的原因。**不 panic、不静默改成别的档**——回落由装配点决定,见模块头。 pub fn apply(&self, builder: ClientBuilder) -> Result { match self { Self::Direct => Ok(builder.no_proxy()), @@ -136,159 +179,100 @@ impl ProxySetting { // 这一档要的正是后者。写成 `.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)) - } + Self::Manual { url } => Ok(builder.proxy(manual_proxy(url)?)), } } } +/// **口令脱敏的唯一实现**:`scheme://user:password@host` → `scheme://user:***@host`。 +/// +/// 规则(三条都要背下来,改它之前先看 [`Self`] 的用例): +/// * 只动 **userinfo**(`//` 之后、第一个 `/` `?` `#` 之前,最后一个 `@` 的左边)——路径里、 +/// 查询里出现 `@` / `:` 一律不碰; +/// * userinfo 里**没有 `:`**(只有用户名、没有口令)⇒ 原样返回:用户名不是凭据,打掉它只会让用户 +/// 看不出自己在用哪个账号; +/// * 没有 userinfo / 整串不是一个 URL ⇒ **逐字原样**返回(脱敏函数不许顺手把正常地址改坏, +/// 比如补上尾斜杠、改大小写、百分号归一)。 +#[must_use] +pub fn redact_proxy_url(raw: &str) -> String { + // 1. 跳过 scheme(没有 `://` 就整串当 authority,`127.0.0.1:7890` 这种写法也照样处理)。 + let authority_start = raw.find("://").map_or(0, |at| at + 3); + let tail = &raw[authority_start..]; + let authority_end = tail.find(['/', '?', '#']).unwrap_or(tail.len()); + let authority = &tail[..authority_end]; + // 2. 最后一个 `@` 之前是 userinfo(口令里可以有 `@`,host 里不能)。 + let Some((userinfo, host)) = authority.rsplit_once('@') else { + return raw.to_string(); + }; + // 3. 只有用户名、没有口令 ⇒ 原样(用户名不是凭据)。 + let Some((user, _password)) = userinfo.split_once(':') else { + return raw.to_string(); + }; + format!( + "{}{user}:***@{host}{}", + &raw[..authority_start], + &tail[authority_end..] + ) +} + +/// `manual` 档的解析与判定(`apply` 与 `problem` 的**同一条**路)。 +/// +/// 顺序有讲究:先自己按 reqwest 的宽口径(少写 scheme 也认)`Url::parse` 一遍,理由是两件事—— +/// ① 拿 scheme 做白名单(`Proxy::all` 对 `socks5://` 也返回 `Ok`,见模块头「非法值不得穿透」第 2 条); +/// ② 拿**能读**的错因(`url::ParseError` 的 Display 是 `invalid port number` 这种, +/// 而 `Proxy::all` 的 Display 只有一句 `builder error`)。 +fn manual_proxy(url: &str) -> Result { + let shown = redact_proxy_url(url); + // 宽口径只补到「少写 scheme」为止:整串已经有 `://` 就直接按原样解析,**不许**再套一层 + // `http://`——那会把 `http://127.0.0.1:99999` 变成 `http://http://127.0.0.1:99999`, + // 于是真正的错因(`invalid port number`)被「解析成功了」掩盖,最后只剩 `Proxy::all` 的一句 + // `builder error`。 + let target = if url.contains("://") { + Url::parse(url) + } else { + Url::parse(&format!("http://{url}")) + } + .map_err(|err| { + DownloadError::new( + DownloadErrorKind::InvalidRequest, + format!("代理地址无法解析({shown}):{}", reason(&err)), + ) + })?; + if !matches!(target.scheme(), "http" | "https") { + return Err(DownloadError::new( + DownloadErrorKind::InvalidRequest, + format!( + "本构建不支持 {} 代理(只支持 http / https):{shown}", + target.scheme() + ), + )); + } + reqwest::Proxy::all(url).map_err(|err| { + DownloadError::new( + DownloadErrorKind::InvalidRequest, + format!("代理地址无法解析({shown}):{}", reason(&err)), + ) + }) +} + +/// 把错误的 `source` 链拼成**一句能读的原因**(r1 复审 p3-3)。 +/// +/// 为什么不能只用 `Display`:`reqwest::Error` 的 Display 是 `builder error`(kind 的名字),真正的 +/// 原因(`invalid port number`)挂在 `source()` 上——只印前者等于「为什么」那半句没写。重复的片段 +/// 只留一次(reqwest 会把同一个 kind 套两层)。 +fn reason(err: &(dyn std::error::Error + 'static)) -> String { + let mut parts = vec![err.to_string()]; + let mut source = err.source(); + while let Some(inner) = source { + let text = inner.to_string(); + if !parts.contains(&text) { + parts.push(text); + } + source = inner.source(); + } + parts.join(":") +} + #[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() - ); - } -} +#[path = "proxy/tests.rs"] +mod tests; diff --git a/crates/core/src/download/proxy/tests.rs b/crates/core/src/download/proxy/tests.rs new file mode 100644 index 0000000..d76c52b --- /dev/null +++ b/crates/core/src/download/proxy/tests.rs @@ -0,0 +1,229 @@ +//! [`super`] 的单测:翻译表、口令脱敏、非法/不支持地址、两个默认值。 +//! +//! 从 `proxy.rs` 拆出只为守 400 行硬线(M295 r1 复审 p2-1 加了一族脱敏用例)。 +//! 「请求真的经过代理」的端到端判据不在这里,在 `crates/core/tests/download/engine_mock/proxy.rs`。 + +use super::super::config::{DownloadTuning, EngineConfig}; +use super::super::error::DownloadErrorKind; +use super::{redact_proxy_url, ProxySetting}; + +/// 口令样本:与本仓 `crates/cli/tests/config_secret_test.rs` 同一个纪律——**任何输出里都不许出现它**。 +const SECRET: &str = "PWMUSTNOTLEAK"; + +/// **两键 → 三档**的唯一翻译规则(表驱动,逐档一条)。 +/// +/// 抓得住:① `manual` / `system` 拼错或大小写敏感 ⇒ 对应档红;② 非 manual 档还在读 `proxy_url` +/// (`none` + 一个地址仍进 `Manual`)⇒ 第 2、3、4 条红;③ `manual` + 空地址落成 `Direct` +/// (而不是设置页承诺的「用系统代理」)⇒ 第 5、6 条红;④ 地址没 trim、模式没小写归一 ⇒ 第 8 条红。 +/// 抓不住:这些档在真客户端上的行为(那是 `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" + ); +} + +/// **口令不许出现在任何对外字符串里**(r1 复审 p2-1 的判据)。 +/// +/// 覆盖四条真实出口:CLI 的生效值行(`describe`→`summary`)、解析失败文案(`problem` 与 `apply` +/// 同一条)、`Debug`(配置/引擎被打进日志时)、以及脱敏函数本身。 +/// +/// 变异:`redact_proxy_url` 直接返回 `raw.to_string()` ⇒ 本用例第一段就红(`contains(SECRET)`), +/// 红的样子是 `代理口径的字符串里出现了口令:manual(http://user:PWMUSTNOTLEAK@…)`。 +#[test] +fn a_password_in_the_proxy_url_never_reaches_a_user_visible_string() { + let with_password = format!("http://user:{SECRET}@127.0.0.1:7890"); + let setting = ProxySetting::Manual { + url: with_password.clone(), + }; + + // ① 生效值那一行(CLI stderr)。 + let line = DownloadTuning { + proxy: setting.clone(), + ..DownloadTuning::default() + } + .summary(); + assert!(!line.contains(SECRET), "生效值行泄漏口令:{line}"); + assert!( + line.contains("代理=manual(http://user:***@127.0.0.1:7890)"), + "用户名要留、口令要打成 `***`(否则用户看不出是哪个账号):{line}" + ); + + // ② Debug(配置 / 引擎 / `assert_eq!` 的失败信息都会走到这里)。 + let debugged = format!("{setting:?}"); + assert!(!debugged.contains(SECRET), "Debug 泄漏口令:{debugged}"); + assert!(debugged.contains("user:***@"), "{debugged}"); + + // ③ 解析失败的文案(`problem()` 与 `apply()` 同源)。 + let broken = ProxySetting::Manual { + url: format!("http://user:{SECRET}@127.0.0.1:99999"), + }; + let problem = broken.problem().expect("端口越界必须报错"); + assert!(!problem.contains(SECRET), "回落提示泄漏口令:{problem}"); + assert!(problem.contains("user:***@"), "{problem}"); + let error = broken + .apply(reqwest::Client::builder()) + .expect_err("端口越界必须是有界错误"); + assert_eq!(error.kind, DownloadErrorKind::InvalidRequest); + assert!(!error.message.contains(SECRET), "{}", error.message); + + // ④ 函数本身:`redact_proxy_url` 是唯一实现,宿主不许绕开它自己拼地址。 + assert_eq!( + redact_proxy_url(&with_password), + "http://user:***@127.0.0.1:7890" + ); +} + +/// **没口令的地址逐字不变**(同一族的反向断言:脱敏不许把正常地址改坏)。 +/// +/// 变异证明:把 `redact_proxy_url` 改成「用 `Url::parse` 再 `to_string`」(补尾斜杠、小写化、 +/// 百分号归一)⇒ 第 1、3、4 条红;把「只有用户名、没有口令」也打掉 ⇒ 第 2 条红。 +#[test] +fn a_proxy_url_without_a_password_is_left_byte_identical() { + let untouched = [ + "http://127.0.0.1:7890", + "http://user@127.0.0.1:7890", + "127.0.0.1:7890", + "https://proxy.example:8443/v1", + "http://127.0.0.1:7890/path@with@at?q=a:b#frag", + ]; + for url in untouched { + assert_eq!(redact_proxy_url(url), url, "不许动没口令的地址:{url}"); + } + // 口令里带 `@` / 路径里带 `:` 这两种「分割点容易被挑错」的形态。 + assert_eq!( + redact_proxy_url("http://user:p@ss@127.0.0.1:7890"), + "http://user:***@127.0.0.1:7890" + ); + assert_eq!( + redact_proxy_url("http://user:pw@127.0.0.1:7890/a:b"), + "http://user:***@127.0.0.1:7890/a:b" + ); +} + +/// **两类「用不了」都走有界错误,且原因能读**(r1 复审 p3-1 / p3-3)。 +/// +/// * `socks5://`:`Proxy::all` 会返回 `Ok`,但本构建(reqwest 没开 `socks` feature)根本用不了它; +/// 不拦的话用户只看到「所有候选源都失败了」,把原因推给源。现在点名 scheme。 +/// * 端口越界:原因串必须是 `invalid port number` 这种,不许只有一句 `builder error`。 +/// +/// 变异:删掉 scheme 白名单 ⇒ 第一条红(`problem()` 变 `None`);把 `reason()` 换成只印 `Display` +/// ⇒ 第二条红(文案里没有 `invalid port number`)。 +#[test] +fn unsupported_schemes_and_bad_ports_explain_themselves() { + let socks = ProxySetting::Manual { + url: format!("socks5://user:{SECRET}@127.0.0.1:1080"), + }; + let problem = socks.problem().expect("本构建用不了的 scheme 必须报错"); + assert!(problem.contains("不支持 socks5"), "{problem}"); + assert!( + problem.contains("只支持 http / https"), + "文案要说清能用什么:{problem}" + ); + assert!(!problem.contains(SECRET), "{problem}"); + assert!(socks.apply(reqwest::Client::builder()).is_err()); + + let bad_port = ProxySetting::Manual { + url: "http://127.0.0.1:99999".to_string(), + }; + let problem = bad_port.problem().expect("端口越界必须报错"); + assert!( + problem.contains("invalid port number") || problem.contains("端口"), + "原因要能读(`builder error` 等于没说):{problem}" + ); + assert!(!problem.contains("builder error"), "{problem}"); + + // 正常档与合法地址没有问题(否则 GUI 会天天挂一条假警告)。 + assert!(ProxySetting::Direct.problem().is_none()); + assert!(ProxySetting::System.problem().is_none()); + assert!(ProxySetting::Manual { + url: "http://127.0.0.1:7890".to_string() + } + .problem() + .is_none()); +} + +/// **两个默认值故意不同**(模块头那段的守卫):没读设置的子系统保持 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() + ); +} diff --git a/crates/core/tests/download/engine_mock/proxy.rs b/crates/core/tests/download/engine_mock/proxy.rs index 26cce11..6773317 100644 --- a/crates/core/tests/download/engine_mock/proxy.rs +++ b/crates/core/tests/download/engine_mock/proxy.rs @@ -269,3 +269,45 @@ async fn an_unparseable_proxy_url_falls_back_to_direct_and_says_why() { assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed); std::fs::remove_dir_all(&harness.dir).ok(); } + +/// **`socks5://` 这类「合法但本构建用不上」的地址**(r1 复审 p3-1)。 +/// +/// 以前 `Proxy::all("socks5://…")` 返回 `Ok` ⇒ `proxy_problem()` 是 `None` ⇒ 用户只看到「所有候选源 +/// 都失败了」,把原因推给源(socks5 在国内很常见)。现在它是**有界错误**:走同一条「回落直连 + +/// 原因留在引擎上」的路,且文案点名 scheme、不含口令。 +/// +/// 变异:删掉 `manual_proxy` 里的 scheme 白名单 ⇒ 本用例第一句红(`proxy_problem()` 变 `None`)。 +#[tokio::test] +async fn an_unsupported_proxy_scheme_is_refused_with_a_readable_reason() { + let _serial = serial().await; + const SECRET: &str = "PWMUSTNOTLEAK"; + let socks = ProxySetting::Manual { + url: format!("socks5://user:{SECRET}@127.0.0.1:1080"), + }; + let harness = engine_with_proxy(socks.clone(), "engine-proxy-socks"); + + let problem = harness + .engine + .proxy_problem() + .expect("本构建用不了的 scheme 必须留下原因"); + assert!(problem.contains("不支持 socks5"), "{problem}"); + assert!( + problem.contains("只支持 http / https"), + "文案要说清能用什么:{problem}" + ); + assert!(!problem.contains(SECRET), "回落提示泄漏口令:{problem}"); + assert_eq!( + harness.engine.config().proxy, + socks, + "配置字段仍是用户写的值" + ); + + // 回落直连 ⇒ 有界失败,不是 panic。 + let error = harness + .engine + .download_bytes(request_for(&harness)) + .await + .expect_err("回落直连打 .invalid 必须失败"); + assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed); + std::fs::remove_dir_all(&harness.dir).ok(); +} diff --git a/crates/presenter/src/host/settings/download.rs b/crates/presenter/src/host/settings/download.rs index ce296ce..17d5cc9 100644 --- a/crates/presenter/src/host/settings/download.rs +++ b/crates/presenter/src/host/settings/download.rs @@ -80,6 +80,13 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView { let region_value = text(env, "download.region"); let mirror_value = text(env, "download.mirror_preference"); let proxy_value = text(env, "download.proxy_mode"); + // M295 r1(复审 p2-2):非法 / 本构建用不上的代理地址 ⇒ 引擎回落**直连**。这条提示是 GUI 侧 + // 唯一的可见面(CLI 打在 stderr)——没有它,用户以为流量在代理里、实际裸奔。 + // 判定与引擎同源(`ProxySetting::problem` = `apply` 的同一个 `manual_proxy`),文案已脱敏。 + let proxy_note = tuning + .proxy + .problem() + .map(|problem| format!("已回落到直连:{problem}")); SettingGroupView::new("download", "下载与镜像(D7)") .with(SettingRowView::choice( unlucky_core::download::config::KEY_CONCURRENCY, @@ -149,10 +156,15 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView { )) // M295:这一行只在 manual 档生效;别的档下地址被**忽略**(值仍留在文件里),地址解析不了 // 时引擎回落直连并说明原因——三件事都写进文案,否则「填了没生效」又得靠用户猜。 + // r1 复审 p2-2:`proxy_note` 是**运行时**结论(这一档真的用不了),独占一行接在静态说明后面 + // ——「回落了却不说」等于让用户在以为走代理的情况下裸奔。 .with(SettingRowView::text( "download.proxy_url", "HTTP 代理", - "只在 manual 模式生效(其它模式忽略这段地址);manual 留空则用系统代理;地址写法不对时按直连处理,原值保留", + &with_note( + "只在 manual 模式生效(其它模式忽略这段地址);manual 留空则用系统代理;地址写法不对时按直连处理,原值保留".to_string(), + proxy_note.as_deref(), + ), &text(env, "download.proxy_url"), )) // M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把 diff --git a/crates/presenter/src/host/settings/download_tests/selection_and_proxy.rs b/crates/presenter/src/host/settings/download_tests/selection_and_proxy.rs index 14870b2..b3ccd50 100644 --- a/crates/presenter/src/host/settings/download_tests/selection_and_proxy.rs +++ b/crates/presenter/src/host/settings/download_tests/selection_and_proxy.rs @@ -131,3 +131,70 @@ fn the_two_source_selection_keys_reach_the_tuning() { assert_eq!(tuning.mirror_preference, MirrorPreference::Official); std::fs::remove_dir_all(&dir).ok(); } + +/// **回落必须被用户看见**(r1 复审 p2-2 的 GUI 侧判据)。 +/// +/// 断的是**最终的文案**(设置页「HTTP 代理」那一行):出现「已回落到直连」+ 原因,独占一行,且 +/// 原因里没有口令;合规地址那一行**没有**这句(否则天天挂一条假警告)。 +/// +/// 同源证明:这一行的原因与引擎的 `proxy_problem()` 是同一串(宿主调 `ProxySetting::problem`, +/// 引擎走 `apply`,两者共用 `manual_proxy` 那一个判定)。 +/// +/// 变异:删掉 `group()` 里的 `proxy_note` ⇒ 第一段红(文案里没有「已回落到直连」);让 +/// `describe`/错误文案回到未脱敏的原串 ⇒ 第三段红(`contains(SECRET)`)。 +#[test] +fn a_broken_proxy_url_is_shown_as_falling_back_to_direct() { + use unlucky_core::download::config::KEY_PROXY_URL; + + const SECRET: &str = "PWMUSTNOTLEAK"; + let (dir, env) = env_with( + "proxy-hint-broken", + serde_json::json!({ "download": { + "proxy_mode": "manual", + "proxy_url": format!("http://user:{SECRET}@127.0.0.1:99999"), + }}), + ); + let group_view = group(&env); + let url_row = row(&group_view, KEY_PROXY_URL); + assert!( + url_row.description.contains("\n已回落到直连:"), + "用不了的代理地址必须在设置页说出来(独占一行):{:?}", + url_row.description + ); + assert!( + url_row.description.contains("99999"), + "提示里要带上用户写的那个地址(脱敏后):{}", + url_row.description + ); + assert!( + !url_row.description.contains(SECRET), + "提示里泄漏了代理口令:{}", + url_row.description + ); + + // 与引擎同源:宿主这一行说的就是引擎实际记录的那条原因。 + std::fs::create_dir_all(env.layout.cache()).expect("cache 目录"); + let problem = crate::host::engine::engine(&env) + .proxy_problem() + .map(str::to_string) + .expect("引擎侧也该记录这条原因"); + assert!( + url_row.description.contains(&problem), + "设置页与引擎说的不是同一件事:{problem}" + ); + std::fs::remove_dir_all(&dir).ok(); + + // 合规地址 ⇒ 不许有这条提示(假警告会让真的那条失效)。 + let (dir, env) = env_with( + "proxy-hint-ok", + serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": "http://127.0.0.1:7890" } }), + ); + let ok_group = group(&env); + let ok_row = row(&ok_group, KEY_PROXY_URL); + assert!( + !ok_row.description.contains("已回落到直连"), + "合规地址不该有回落提示:{}", + ok_row.description + ); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/docs/download-settings.md b/docs/download-settings.md index 9afeab2..a571cae 100644 --- a/docs/download-settings.md +++ b/docs/download-settings.md @@ -208,6 +208,47 @@ config/launcher.json * **`reqwest` 会替用户补 `http://`**:`Proxy::all("127.0.0.1:7890")` 是合法写法(少写 scheme 不算 「解析不了」)。真正解析不了的只剩端口越界(`…:99999`)、方括号不闭合这一类——用例挑的是前者。 +### 5.3 口令脱敏与「回落必须被看见」(M295 r1 复审 p2-1 / p2-2) + +**p2-1:口令不许出现在任何用户可见 / 会落盘的字符串里。** `proxy_url` 允许带 userinfo +(`http://user:password@host:port`),而这个键在本仓就是凭据(schema 是 `AiPolicy::sensitive()`, +`ull config get` 打 ``,`crates/cli/tests/config_secret_test.rs` 专门守它)。M295 第一版 +把 `manual` 的原串整条写进 `summary()`(→ CLI stderr,进而 CI 日志 / 用户的截图)与解析失败文案, +等于把口令还原成明文,而且**不需要用户犯错**。现在: + +* **唯一实现**:`core::download::proxy::redact_proxy_url`——`user:password@` → `user:***@`(用户名保留、 + 口令打掉;userinfo 里没有 `:` ⇒ **逐字原样**;路径/查询里的 `@` `:` 一律不碰,也不做任何 URL 归一); +* **四个出口都过它**:`ProxySetting::describe()`(`summary()` / CLI stderr)、`apply()` 的错误文案 + (`proxy_problem()` / GUI 提示)、手写的 `Debug`(配置与引擎被打进日志时)、`problem()`; + 宿主**不许**自己再拼一遍地址; +* 例外(刻意留着):设置页那个**输入框**显示文件原值——它是编辑面,把 `user:***@host` 回填进去、 + 用户再点「应用」就会把掩码写回配置、把真口令冲掉。文件本身也是明文,掩码的价值在输出面不在编辑面。 + +**p2-2:回落必须被看见。** 引擎对「解析不了 / 本构建用不了」的处置是**回落直连**(见下),M295 第一版 +只有 CLI 说了,GUI 一个字都没有——用户在「直连能通、但他要求走代理(公司策略 / 隐私)」的场景下会以为 +流量在代理里,实际裸奔。现在两个宿主都调**同一个判定** `ProxySetting::problem()`(与 `apply` 共用 +`manual_proxy`,所以不会各说一套): + +| 面 | 形态 | 何时出现 | +|---|---|---| +| CLI | 装配引擎时一行 stderr:`下载代理未生效,本次按直连:<原因>` | 每条命令装配引擎时(`download::engine`) | +| GUI 设置页 | 「HTTP 代理」这一行的第二段(`\n已回落到直连:<原因>`) | 打开设置页即见(`host::settings::download::group`) | + +**取舍(显式写下来)**:为什么是「回落直连」而不是「拒绝下载」?拒绝对用户更不挡路——下载一个坏掉的 +代理地址不该让整个安装停摆,而设置字符串又很容易写错(少个端口、多了个 scheme)。代价是「用户以为走 +代理」,所以配一条硬要求:**回落必须有可见提示**,两处都要有(GUI 常驻、CLI 每次装配一行)。 +要改成 fail-closed 只需让 `build_client` 的错误直接冒泡,但那是产品决定,不在 M295 的范围。 + +**p3-1 / p3-3:`Proxy::all` 会骗人,原因串也不好读。** 两处补丁: + +* `socks5://…`(以及任何非 http/https)在 `Proxy::all` 里返回 **`Ok`**,但本构建 reqwest 没开 `socks` + feature ⇒ 以前表现为「没有任何提示,然后所有候选源都失败了」,把原因推给源。现在 `manual_proxy` + 先用 `Url::parse` 拿 scheme 做白名单,不支持的直接给有界错误「本构建不支持 socks5 代理(只支持 + http / https):…」,于是它走已有的「回落直连 + 告知」路径; +* 原因串不再只有 `builder error`:`reason()` 沿 `source()` 链拼成一句(`builder error:invalid port + number` → 现在先自己解析,通常只剩 `invalid port number`);宽口径只补到「少写 scheme」为止, + 已经带 `://` 的串**不再**套一层 `http://`(那会把真错因掩盖成「解析成功」)。 + ## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原) | # | 变异(只改实现侧) | 守卫用例 | 结果 | @@ -233,7 +274,11 @@ config/launcher.json | M19(M295) | `ProxySetting::apply` 的 `System` 档改成 `.no_proxy()` | 同上 | 红(system 档不再跟随环境变量 ⇒ `AllSourcesFailed`) | | M20(M295) | presenter 的 `tuning_of` 把 `proxy_mode` / `proxy_url` 传 `None`(= 没读这两个键) | `host::settings::download::tests::selection_and_proxy::the_proxy_keys_travel_from_the_file_to_the_engine_config` | 红(`left: (Direct, None)` vs `right: (System, None)`;2 passed 1 failed) | | M21(M295) | CLI 的 `download::tuning` 同上 | `cli::download::tests::proxy::the_proxy_settings_reach_the_cli_engine_and_the_console_line` | 红(`left: Direct` vs `right: Manual { url: … }`;1 passed 1 failed) | -| M22(M295) | `ProxySetting::apply` 的 `Proxy::all(url)?` 改成 `expect(...)` | `…::engine_mock::proxy::an_unparseable_proxy_url_falls_back_to_direct_and_says_why`(+ core 单测 `a_broken_proxy_url_is_a_bounded_error_not_a_panic`) | 红(两处都 panic:`代理地址: … InvalidPort`) | +| M22(M295) | `ProxySetting::apply` 的 `Proxy::all(url)?` 改成 `expect(...)` | `…::engine_mock::proxy::an_unparseable_proxy_url_falls_back_to_direct_and_says_why`(+ core 单测 `proxy::tests::unsupported_schemes_and_bad_ports_explain_themselves`) | 红(两处都 panic:`代理地址: … InvalidPort`) | +| M23(M295 r1) | `redact_proxy_url` 开头直接 `return raw.to_string()` | `download::proxy::tests::a_password_in_the_proxy_url_never_reaches_a_user_visible_string`(另两条同族一起红) | 红(`test result: FAILED. 9 passed; 3 failed`)。红原文:`本构建不支持 socks5 代理(只支持 http / https):socks5://user:PWMUSTNOTLEAK@127.0.0.1:1080` | +| M24(M295 r1) | presenter `group()` 里 `proxy_note` 换成 `None`(= GUI 不再说「已回落到直连」) | `host::settings::download::tests::selection_and_proxy::a_broken_proxy_url_is_shown_as_falling_back_to_direct` | 红(`FAILED. 3 passed; 1 failed`):`用不了的代理地址必须在设置页说出来(独占一行):"只在 manual 模式生效…原值保留"` | +| M25(M295 r1) | `manual_proxy` 的 scheme 白名单写成 `if false`(= socks5 又变成「解析成功」) | `download::proxy::tests::unsupported_schemes_and_bad_ports_explain_themselves`(+ engine_mock 的 socks 用例) | 红(`FAILED. 11 passed; 1 failed`):`本构建用不了的 scheme 必须报错` | +| M26(M295 r1) | 错误文案的错因换回 `builder error`(= p3-3 报的那个形态) | 同上 | 红(`FAILED. 11 passed; 1 failed`):`原因要能读(「builder error」等于没说):代理地址无法解析(http://127.0.0.1:99999):builder error` | **M20 / M21 的一条附注**:直接把那两个字段**删掉**根本编译不过——两个宿主的 `RawTuning` 是完整字段 字面量(没有 `..RawTuning::default()`),编译器会报 `E0063 missing fields proxy_mode and proxy_url`。 @@ -307,13 +352,19 @@ M193 时期这一组把「GitHub 加速前缀」的三态画成三行(`GitHub 没开 `client-proxy-system` feature ⇒ `system` 档只认环境变量(`HTTP_PROXY` 等)。所以「我在 Windows 设置里开了系统代理」这件事对 `system` 档**无效**,这是已知限制,不是本页的承诺; * PAC 脚本(`AutoConfigURL` / `PROXY.pac`)没测,代码里也没有任何 PAC 支持; - * 需要认证的代理(`Proxy-Authorization` / `Proxy::basic_auth`)没测; - * SOCKS 代理没测(reqwest 的 `socks` feature 本仓没开); + * 需要认证的代理(`Proxy-Authorization` / `Proxy::basic_auth`)没测;userinfo 里的账号口令 + 是否被 reqwest 转成 `Proxy-Authorization` 也没验(我们只保证**文案**里没有口令,见 §5.3); + * SOCKS 代理**不支持**(reqwest 的 `socks` feature 没开)。M295 r1 起它不再是「静默直连」: + `socks5://` 会被 scheme 白名单拦下有界错误并提示「本构建不支持 socks5 代理」(§5.3),但「能用 + SOCKS」这件事仍然没有; * HTTPS 目标的 `CONNECT` 隧道没测:夹具只讲明文 HTTP,`manual` 档对 https 目标的行为只在 「`Proxy::all` 会拦下 https」这一层有依据; * 代理自身连不上/半死时被归到 `Network` 还是 `Timeout`(以及会不会被 `attempts_for` 重试)没测; * **没在真的代理软件上点过**(Clash / v2rayN / 公司代理),也没在「只靠 `HTTP_PROXY` 上网」的 环境里回归过 `none` 由「碰巧跟随环境变量」变成真直连这件事——那是一次真实的行为变化(见 §5.2)。 + * 「回落提示」只在 CLI 的真实二进制上端到端验过(`ull install --dry-run` 两行都不含口令); + **GUI 那一行只在单元层面验**(`group()` 产出的 `description` 文本),没在真窗口里看过它渲染、 + 折行、以及它与 424 列表单宽度的相容性(设置行的渲染在 `app-iced`,不在本页 scope)。 * **发布版 GUI 的「下载 JDK」页仍不认代理两键**(M295):那一处是独立装配点,直接用 `EngineConfig::default()`(= `system`,跟随环境变量)。它与「并发 / 三段时间」是同一条已知缺口 (`app-iced/…/java/downloads/engine.rs` 模块头的对照表),要接就得把那一处并进 `wanted(cache_root)`。