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

+13 -1
View File
@@ -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 是两件事**,描述里必须都写出来——用户最容易把
@@ -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();
}