fix(download): M295 r1 —— 代理口令脱敏、回落可见、socks5 与错因有话说

复审 r1(p2-2items / fix-then-merge)两条 p2 都是本轮新开的「用户被骗」面,三条 p3 一并收。

p2-1(凭据泄漏面):`proxy_url` 允许 userinfo,而这个键在本仓就是凭据(schema 是
`AiPolicy::sensitive()`、`config get` 打 `<redacted>`、`config_secret_test.rs` 专门守它)。第一版把
manual 的原串整条写进 `summary()`(→ CLI stderr → CI 日志 / 截图)与解析失败文案,**不需要用户犯错**。

- `redact_proxy_url` 是唯一实现:`user:password@` → `user:***@`(用户名保留、口令打掉;只有用户名
  没有口令 ⇒ 原样;路径/查询里的 `@` `:` 一律不碰,也不做任何 URL 归一)。
- 四个出口全过它:`describe()`(`summary()`/stderr)、`apply()` 的错误文案(`proxy_problem()`/
  GUI 提示)、手写的 `Debug`(配置与引擎被打进日志时)、`problem()`。
- 刻意例外(写进 docs §5.3):设置页那个输入框仍显示文件原值——它是编辑面,回填掩码会在用户点
  「应用」时把真口令冲掉。

p2-2(GUI 对「非法地址 ⇒ 回落直连」完全静默):`proxy_problem()` 的消费者只有 CLI。

- `ProxySetting::problem()` 是新增的**纯判定**(与 `apply` 共用 `manual_proxy`):宿主用它给提示,
  不必造 client。两个宿主都调它,优先级写清:GUI 常驻(设置页「HTTP 代理」行第二段
  `\n已回落到直连:<原因>`)、CLI 每次装配引擎一行 stderr。
- 取舍写进 docs §5.3:回落直连(而不是拒绝下载)不挡用户,代价是「以为走代理」,所以回落**必须**
  可见;fail-closed 是产品决定,不在本轮。

p3-1:`socks5://` 在 `Proxy::all` 里返回 `Ok`,但本构建没开 socks feature ⇒ 以前静默变成「所有候选
源都失败了」。现在 `manual_proxy` 先自己 `Url::parse` 拿 scheme 做白名单,不支持的直接有界错误
(「本构建不支持 socks5 代理(只支持 http / https)」)⇒ 走已有的回落 + 告知路径。
p3-3:错因不再只有 `builder error`:`reason()` 沿 `source()` 链拼一句;宽口径只补到「少写 scheme」
为止(已带 `://` 不再套一层 `http://`,否则真错因会被掩盖成「解析成功」)。实测文案:
`代理地址无法解析(http://127.0.0.1:99999):invalid port number`。
p3-2:两个测试文件已在 scope 里登记,本轮未动。

判据(都落在最终字符串 / 真正可见面上):
- `a_password_in_the_proxy_url_never_reaches_a_user_visible_string`:生效值行 / 错误文案 / `Debug`
  四处都不含口令子串且保留 `user:***@`;`a_proxy_url_without_a_password_is_left_byte_identical`
  守「没口令的地址逐字不变」。
- `a_broken_proxy_url_is_shown_as_falling_back_to_direct`(presenter):设置页那一行含
  `\n已回落到直连:`、带(脱敏后的)地址、不含口令,且与 `engine.proxy_problem()` 是同一串;
  合规地址没有这条提示。
- `the_console_line_never_carries_the_proxy_password`(CLI):`announce` 那一行与回落提示都不含口令。
- `unsupported_schemes_and_bad_ports_explain_themselves`(core)+ `an_unsupported_proxy_scheme_is_
  refused_with_a_readable_reason`(engine)。
- 变异 M23-M26 记在 docs §6(redact 返回原文 ⇒ 3 条红;删 `proxy_note` ⇒ GUI 提示红;白名单写
  `if false` ⇒ socks 红;错因换回 `builder error` ⇒ 红)。
- 端到端(真二进制 `ull install 1.20.1 --dry-run`,`CARGO_TARGET_DIR` 同前):口令样本
  `PWMUSTNOTLEAK` 在 stdout+stderr 里 **0 次**,两行都是 `user:***@`;socks5 那档打出
  「本构建不支持 socks5 代理(只支持 http / https)」。
- 门禁:四 crate `--all-targets` check / 测试(3206 passed, 0 failed, 45 binaries)/
  `clippy --all-targets -- -D warnings` / `fmt --all --check`,另 `check --workspace --all-targets`。
This commit is contained in:
AskaEth committed 2026-11-13 11:10:30 +08:00
1 parent d584a2c42e
commit c182d8d5c9
7 files changed
+584 -167

No files matched your search

@@ -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();
}