From 422f198c845ff21e17459b30b3ddbefea723f322 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 11:47:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(download):=20M295=20r2=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E4=BB=A3=E7=90=86=E5=9C=B0=E5=9D=80=E5=9B=9B=E7=B1=BB?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E5=90=84=E4=B8=80=E5=8F=A5=E4=B8=AD=E6=96=87?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=91=8A=20reqwest=20/=20url=20?= =?UTF-8?q?=E7=9A=84=E5=8E=9F=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 复审退回的形态:`代理地址无法解析(http://127.0.0.1:99999):builder error`。第一版靠 `Proxy::all` 的报错(只有这一句),第二版靠 `url::ParseError` 的 Display(英文 `invalid port number`,而且 reqwest 的宽口径会让「解析成功/失败」本身不可靠)——两种都还是把别人的原文当结论。 - `validate()` 自己判四类,各给一句中文:scheme 不支持 / 缺少主机名 / 端口(空、0、越界)/ 空地址; `manual_proxy` 先过它,再 `Proxy::all`。 - 残余情形(validate 放行但 reqwest 不认,例如 userinfo 有非法字符):中文结论在前,reqwest 原文 只作为括号里的证据。 - 判据改成「文案里命中该类中文原因,且不含 `builder error` / `invalid port number`」,四类表驱动 + 合法写法(含 `[::1]:8080` 与少写 scheme)不许被拒。 - 实测(真二进制,`ull install 1.20.1 --dry-run`,口令样本 PWMUSTNOTLEAK): `代理地址用不了(http://user:***@127.0.0.1:99999):端口 99999 不是 1–65535 之间的数字` `代理地址用不了(socks5://user:***@127.0.0.1:1080):本构建不支持 socks5 代理(只支持 http / https)` 口令出现 0 次。 - 变异(本轮重做):关掉端口判定 ⇒ 红,且红的样子正是「地址写法不被接受(builder error:invalid port number)」——证明没有自己的判定时原文就会漏出来;关掉主机名判定 ⇒ 红;scheme 白名单写 `if false` ⇒ 红;`redact_proxy_url` 返回原文 ⇒ 3 条红(口令泄漏)。见 docs §6 M23/M25/M26/M27。 - 门禁(含塔点名的整棵 `-p unlucky-core --lib`):914 passed / 0 failed;四 crate 测试 45 个 binary、3206 passed / 0 failed;clippy `-D warnings`、`fmt --all --check`、 `check --workspace --all-targets` 全 0。 --- crates/core/src/download/proxy.rs | 104 ++++++++++++------ crates/core/src/download/proxy/tests.rs | 68 ++++++++---- .../core/tests/download/engine_mock/proxy.rs | 4 +- docs/download-settings.md | 29 +++-- 4 files changed, 142 insertions(+), 63 deletions(-) diff --git a/crates/core/src/download/proxy.rs b/crates/core/src/download/proxy.rs index 4ad586d..27324e3 100644 --- a/crates/core/src/download/proxy.rs +++ b/crates/core/src/download/proxy.rs @@ -47,10 +47,13 @@ //! //! # 非法值不得穿透(两类都走同一条路:有界错误 + 回落直连 + 告知) //! -//! 1. **解析不了**(端口越界 `…:99999`、方括号不闭合…):[`ProxySetting::apply`] 返回 -//! `InvalidRequest` + 带(脱敏后)原串与**能读**的原因,**不 panic**; -//! 2. **解析得了但本构建用不上**(`socks5://` 等:reqwest 没开 `socks` feature,而 -//! `Proxy::all` 对它们照样返回 `Ok`):同样是有界错误,文案直接点名 scheme。 +//! 1. **用不了**:由 `validate` **自己**判四类——空地址 / scheme 不支持(`socks5://` 等)/ +//! 缺少主机名 / 端口不合法(空、`0`、越界)。每类各一句中文原因,**不读** reqwest 的 +//! `builder error`、也不匹配 `url` 的英文文案(那是「摊原文」,等于没解释); +//! 2. **reqwest 层残余的拒绝**(`validate` 放行但它不认,例如 userinfo 里有非法字符):中文结论在 +//! 前,reqwest 原文只作为括号里的证据。 +//! +//! 两种情况都是 `InvalidRequest` + 带(脱敏后)原串的可读文案,**不 panic**。 //! //! 装配点([`super::engine::DownloadEngine::new`])的处置是「回落直连 + 把原因留在引擎上」 //! (`DownloadEngine::proxy_problem`):一个设置字符串不该让用户连文件都下不了,但也不许它悄悄变成 @@ -58,8 +61,7 @@ //! [`ProxySetting::problem`] 的结论画在「HTTP 代理」那一行(两个宿主都调同一个判定,不各写一份)。 //! //! 一条实测(别把它当 bug 修掉):reqwest 的 `Proxy::all` 对**没有 scheme** 的串会自作主张补 -//! `http://` —— `127.0.0.1:7890` 是合法写法。所以用户少写 `http://` 不算「解析不了」;真正会失败的 -//! 是端口越界(`…:99999`)、方括号不闭合这一类。 +//! `http://` —— `127.0.0.1:7890` 是合法写法。所以「宽口径」只补到「少写 scheme」为止,判据自己算。 //! //! # 不落盘 //! @@ -74,7 +76,6 @@ use std::fmt; use reqwest::ClientBuilder; -use reqwest::Url; use super::error::{DownloadError, DownloadErrorKind}; @@ -217,44 +218,83 @@ pub fn redact_proxy_url(raw: &str) -> String { /// `manual` 档的解析与判定(`apply` 与 `problem` 的**同一条**路)。 /// -/// 顺序有讲究:先自己按 reqwest 的宽口径(少写 scheme 也认)`Url::parse` 一遍,理由是两件事—— -/// ① 拿 scheme 做白名单(`Proxy::all` 对 `socks5://` 也返回 `Ok`,见模块头「非法值不得穿透」第 2 条); -/// ② 拿**能读**的错因(`url::ParseError` 的 Display 是 `invalid port number` 这种, -/// 而 `Proxy::all` 的 Display 只有一句 `builder error`)。 +/// 顺序:**先自己判**([`validate`]:空 / scheme / host / 端口四类各给一句中文原因),再用 +/// `reqwest::Proxy::all` 真的造代理。为什么不靠 `Proxy::all` 的报错:它的 Display 只有一句 +/// `builder error`(原因挂在 `source()` 上,甚至可能是空的),把原文摊给用户等于没解释; +/// 为什么不靠 `url::ParseError` 的 Display:那是英文(`invalid port number`),而且 reqwest 的 +/// 宽口径(给少写 scheme 的串补 `http://`)会让「解析成功/失败」本身就不可靠——端口越界正是 +/// 撞在这上面(r2 复审要求:四类各给一句,不许摊原文)。 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| { + validate(url).map_err(|why| { DownloadError::new( DownloadErrorKind::InvalidRequest, - format!("代理地址无法解析({shown}):{}", reason(&err)), + format!("代理地址用不了({shown}):{why}"), ) })?; - if !matches!(target.scheme(), "http" | "https") { - return Err(DownloadError::new( - DownloadErrorKind::InvalidRequest, - format!( - "本构建不支持 {} 代理(只支持 http / https):{shown}", - target.scheme() - ), - )); - } + // 残余失败(validate 放行、但 reqwest 不认,例如 userinfo 里有非法字符):中文结论在前, + // reqwest 的原文只作为括号里的**证据**,不当结论。 reqwest::Proxy::all(url).map_err(|err| { DownloadError::new( DownloadErrorKind::InvalidRequest, - format!("代理地址无法解析({shown}):{}", reason(&err)), + format!( + "代理地址用不了({shown}):地址写法不被接受({})", + reason(&err) + ), ) }) } +/// 代理地址的**四类**判定:空 / scheme 不支持 / 缺少主机名 / 端口不合法。 +/// +/// 每条都**自己算**(不解析 reqwest 的错误、也不匹配英文文案),所以判定与 reqwest/url 的版本、 +/// 宽口径、错误措辞无关。这里只判「能不能拿去连」,不做归一化——通过之后原串仍会被 +/// `reqwest::Proxy::all` 拿去用(`127.0.0.1:7890` 这种少写 scheme 的写法是合法的)。 +fn validate(raw: &str) -> Result<(), String> { + let text = raw.trim(); + if text.is_empty() { + return Err("地址是空的(manual 档要填 `http://host:port`)".to_string()); + } + let (scheme, rest) = match text.split_once("://") { + Some((scheme, rest)) => (Some(scheme.to_ascii_lowercase()), rest), + None => (None, text), + }; + if let Some(scheme) = &scheme { + if !matches!(scheme.as_str(), "http" | "https") { + return Err(format!("本构建不支持 {scheme} 代理(只支持 http / https)")); + } + } + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + // 最后一个 `@` 之后才是 host[:port](userinfo 里的 `@` `:` 不参与)。 + let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp); + let (host, port) = match host_port.strip_prefix('[') { + // IPv6 字面量:`[::1]:8080` + Some(after_open) => match after_open.split_once(']') { + Some((host, after)) => (host, after.strip_prefix(':')), + None => return Err("IPv6 地址的方括号没有闭合".to_string()), + }, + None => match host_port.rsplit_once(':') { + Some((host, port)) => (host, Some(port)), + None => (host_port, None), + }, + }; + if host.is_empty() { + return Err("缺少主机名(`host` 是空的)".to_string()); + } + if let Some(port) = port { + if port.is_empty() { + return Err("端口是空的(`host:` 后面没有数字)".to_string()); + } + match port.parse::() { + Ok(0) => return Err("端口 0 不是有效端口(要 1–65535)".to_string()), + Ok(_) => {} + Err(_) => return Err(format!("端口 {port} 不是 1–65535 之间的数字")), + } + } + Ok(()) +} + /// 把错误的 `source` 链拼成**一句能读的原因**(r1 复审 p3-3)。 /// /// 为什么不能只用 `Display`:`reqwest::Error` 的 Display 是 `builder error`(kind 的名字),真正的 diff --git a/crates/core/src/download/proxy/tests.rs b/crates/core/src/download/proxy/tests.rs index d76c52b..d8843ea 100644 --- a/crates/core/src/download/proxy/tests.rs +++ b/crates/core/src/download/proxy/tests.rs @@ -149,14 +149,16 @@ fn a_proxy_url_without_a_password_is_left_byte_identical() { ); } -/// **两类「用不了」都走有界错误,且原因能读**(r1 复审 p3-1 / p3-3)。 +/// **四类「用不了」都走有界错误,而且每类各有一句中文原因**(r1 p3-1/p3-3 + r2 复审必修)。 /// -/// * `socks5://`:`Proxy::all` 会返回 `Ok`,但本构建(reqwest 没开 `socks` feature)根本用不了它; -/// 不拦的话用户只看到「所有候选源都失败了」,把原因推给源。现在点名 scheme。 -/// * 端口越界:原因串必须是 `invalid port number` 这种,不许只有一句 `builder error`。 +/// 判据落在**最终文案**上:`problem()` 里出现该类的中文原因,且**没有**把 reqwest/url 的原文 +/// 当结论(`builder error`、`invalid port number` 都不许出现在文案里)。 /// -/// 变异:删掉 scheme 白名单 ⇒ 第一条红(`problem()` 变 `None`);把 `reason()` 换成只印 `Display` -/// ⇒ 第二条红(文案里没有 `invalid port number`)。 +/// 为什么强调「自己算」:reqwest 的 `Proxy::all` 对端口越界只会回一句 `builder error`,而 +/// `url::ParseError` 的 Display 是英文——两种「摊原文」都不算解释(r2 复审就是这么退回的)。 +/// +/// 变异:① 删掉 `validate` 里的端口判定 ⇒ 表格第一行红(落到 reqwest 那句 `builder error`); +/// ② 删掉 scheme 白名单 ⇒ 第二行红(`problem()` 变 `None`);③ 删掉主机名为空的判定 ⇒ 第三行红。 #[test] fn unsupported_schemes_and_bad_ports_explain_themselves() { let socks = ProxySetting::Manual { @@ -171,24 +173,52 @@ fn unsupported_schemes_and_bad_ports_explain_themselves() { 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}"); + // 四类各一句(表驱动):每行都断「命中该类的中文原因」,并断**没有**摊英文原文。 + let cases: [(&str, &str); 5] = [ + ("http://127.0.0.1:99999", "端口 99999 不是 1–65535"), + ("http://127.0.0.1:0", "端口 0 不是有效端口"), + ("http://127.0.0.1:", "端口是空的"), + ("http://:7890", "缺少主机名"), + ("http://", "缺少主机名"), + ]; + for (url, expected) in cases { + let problem = ProxySetting::Manual { + url: url.to_string(), + } + .problem() + .unwrap_or_else(|| panic!("{url} 必须被判为用不了")); + assert!(problem.contains(expected), "{url} ⇒ {problem}"); + assert!( + !problem.contains("builder error") && !problem.contains("invalid port number"), + "{url}:不许把 reqwest / url 的原文当结论:{problem}" + ); + } + // 空地址:`from_raw` 会把它翻成 `System`(manual + 留空 = 用系统代理),所以这种 `Manual` 只能 + // 手拼出来;手拼出来时同样有一句原因,不会静默通过。 + assert!(ProxySetting::Manual { + url: " ".to_string() + } + .problem() + .is_some_and(|problem| problem.contains("地址是空的"))); // 正常档与合法地址没有问题(否则 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() + for ok in [ + "http://127.0.0.1:7890", + "127.0.0.1:7890", + "https://proxy.example:8443/v1", + "http://[::1]:8080", + ] { + assert!( + ProxySetting::Manual { + url: ok.to_string() + } + .problem() + .is_none(), + "{ok} 是合法写法,不该被拒" + ); } - .problem() - .is_none()); } /// **两个默认值故意不同**(模块头那段的守卫):没读设置的子系统保持 M295 之前的行为, diff --git a/crates/core/tests/download/engine_mock/proxy.rs b/crates/core/tests/download/engine_mock/proxy.rs index 6773317..a0ec9b0 100644 --- a/crates/core/tests/download/engine_mock/proxy.rs +++ b/crates/core/tests/download/engine_mock/proxy.rs @@ -253,7 +253,9 @@ async fn an_unparseable_proxy_url_falls_back_to_direct_and_says_why() { .proxy_problem() .expect("地址解析不了必须留下原因(宿主靠它告诉用户)"); assert!(problem.contains("http://127.0.0.1:99999"), "{problem}"); - assert!(problem.contains("代理地址无法解析"), "{problem}"); + assert!(problem.contains("代理地址用不了"), "{problem}"); + assert!(problem.contains("端口 99999"), "原因要能读:{problem}"); + assert!(!problem.contains("builder error"), "{problem}"); assert_eq!( harness.engine.config().proxy, broken, diff --git a/docs/download-settings.md b/docs/download-settings.md index a571cae..9b06b99 100644 --- a/docs/download-settings.md +++ b/docs/download-settings.md @@ -239,15 +239,21 @@ config/launcher.json 代理」,所以配一条硬要求:**回落必须有可见提示**,两处都要有(GUI 常驻、CLI 每次装配一行)。 要改成 fail-closed 只需让 `build_client` 的错误直接冒泡,但那是产品决定,不在 M295 的范围。 -**p3-1 / p3-3:`Proxy::all` 会骗人,原因串也不好读。** 两处补丁: +**p3-1 / p3-3:四类原因自己算,不摊 reqwest / url 的原文。** 两轮补丁的教训:第一版靠 +`Proxy::all` 的报错(只有一句 `builder error`),第二版靠 `url::ParseError` 的 Display(英文 +`invalid port number`,而且 reqwest 的宽口径会让「解析成功/失败」本身不可靠)——两种都还是把别人的 +原文当结论。现在 `validate()` 自己判四类,各类一句中文: -* `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://`(那会把真错因掩盖成「解析成功」)。 +| 情形 | 文案(接在「代理地址用不了(<地址>):」之后) | +|---|---| +| scheme 不是 http/https(`socks5://` 等) | `本构建不支持 socks5 代理(只支持 http / https)` | +| 缺主机名(`http://` / `http://:7890`) | `缺少主机名(host 是空的)` | +| 端口越界(`…:99999`)/ `0` / 空 | `端口 99999 不是 1–65535 之间的数字` / `端口 0 不是有效端口(要 1–65535)` / `端口是空的(host: 后面没有数字)` | +| 空地址 | `地址是空的(manual 档要填 http://host:port)` | + +`validate()` 放行但 reqwest 仍拒绝的残余情形(例如 userinfo 里有非法字符):中文结论在前,reqwest +原文只作为**括号里的证据**(`地址写法不被接受(…)`)。判据是「文案里命中该类中文原因,且不含 +`builder error` / `invalid port number`」——不是「`problem()` 返回了 Some」。 ## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原) @@ -275,10 +281,11 @@ config/launcher.json | 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 单测 `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` | +| M23(M295 r1/r2) | `redact_proxy_url` 开头直接 `return raw.to_string()` | `download::proxy::tests::a_password_in_the_proxy_url_never_reaches_a_user_visible_string`(另两条同族一起红) | 红(`FAILED. 2 passed; 3 failed`)。红原文:`生效值行泄漏口令:… 代理=manual(http://user:PWMUSTNOTLEAK@127.0.0.1:7890) …`;socks 那条也一起漏(`代理地址用不了(socks5://user:PWMUSTNOTLEAK@…)`) | | 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` | +| M25(M295 r1/r2) | `validate` 的 scheme 白名单写成 `if false`(= socks5 又变回「能用」) | `download::proxy::tests::unsupported_schemes_and_bad_ports_explain_themselves`(+ engine_mock 的 socks 用例) | 红(`FAILED. 4 passed; 1 failed`):`本构建用不了的 scheme 必须报错` | +| M26(M295 r2) | `validate` 的端口判定关掉(`if let Some(port) = None::<&str>`) | 同上 | 红(`FAILED. 4 passed; 1 failed`):`http://127.0.0.1:99999 ⇒ 代理地址用不了(…):地址写法不被接受(builder error:invalid port number)`——**没有自己的判定时,reqwest 的原文就会漏出来**(就是 r2 复审看到的那个形态) | +| M27(M295 r2) | `validate` 的「主机名为空」判定关掉 | 同上 | 红(`FAILED. 4 passed; 1 failed`):`http://:7890 ⇒ …地址写法不被接受(builder error:empty host)` | **M20 / M21 的一条附注**:直接把那两个字段**删掉**根本编译不过——两个宿主的 `RawTuning` 是完整字段 字面量(没有 `..RawTuning::default()`),编译器会报 `E0063 missing fields proxy_mode and proxy_url`。