fix(download): M295 r2 —— 代理地址四类原因各一句中文,不再摊 reqwest / url 的原文

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。
This commit is contained in:
AskaEth committed 2026-11-13 11:47:21 +08:00
1 parent c182d8d5c9
commit 422f198c84
4 files changed
+142 -63

No files matched your search

+18 -11
View File
@@ -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`。