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
+579 -162

No files matched your search

+32
View File
@@ -76,3 +76,35 @@ fn the_proxy_settings_reach_the_cli_engine_and_the_console_line() {
assert!(schema.spec(key).is_some(), "{key} 没登记"); 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}");
}
+142 -158
View File
@@ -15,7 +15,7 @@
//! |---|---|---|---| //! |---|---|---|---|
//! | `none`(schema 出厂值,键不存在也走这档) | [`ProxySetting::Direct`] | `.no_proxy()` | 什么都不走:连进程环境里的 `HTTP_PROXY` 也不读 | //! | `none`(schema 出厂值,键不存在也走这档) | [`ProxySetting::Direct`] | `.no_proxy()` | 什么都不走:连进程环境里的 `HTTP_PROXY` 也不读 |
//! | `system` | [`ProxySetting::System`] | **不调任何代理 API** | 跟随进程环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`)= reqwest 的默认行为,也是 M295 之前所有下载的实际行为 | //! | `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 出厂值一致:静默回落、不报错、不中断下载,口径与 //! 未知值 / 空串 ⇒ `none`(与 schema 出厂值一致:静默回落、不报错、不中断下载,口径与
//! `preference` 那两个枚举键相同)。 //! `preference` 那两个枚举键相同)。
@@ -36,13 +36,26 @@
//! `manual` + **空地址** ⇒ [`ProxySetting::System`]:设置页那行文案承诺过「manual 模式生效;留空则 //! `manual` + **空地址** ⇒ [`ProxySetting::System`]:设置页那行文案承诺过「manual 模式生效;留空则
//! 用系统代理」,这里按文案兑现。不选 `Direct` —— 那会让「留空」变成第三种没人写过的语义。 //! 用系统代理」,这里按文案兑现。不选 `Direct` —— 那会让「留空」变成第三种没人写过的语义。
//! //!
//! # 非法地址不得穿透 //! # 口令:**所有**用户可见 / 会落盘的字符串都先脱敏(r1 复审 p2-1)
//! //!
//! `proxy_url` 写成解析不了的串时,[`ProxySetting::apply`] 返回**有界错误** //! `proxy_url` 允许带 userinfo(`http://user:password@host:port`),而这个键在本仓就是凭据
//! ([`DownloadErrorKind::InvalidRequest`](super::error::DownloadErrorKind::InvalidRequest) + //! (schema 是 `AiPolicy::sensitive()`、`ull config get` 打 `<redacted>`)。M295 第一版把
//! 带原串的可读文案),**不 panic**。装配点([`super::engine::DownloadEngine::new`])的处置是 //! `manual` 的原串整条写进 `summary()`(→ CLI stderr)与解析失败文案,等于把口令还原成明文。
//! 「回落直连 + 把原因留在引擎上」(`DownloadEngine::proxy_problem`):一个设置字符串不该让用户 //! 现在**唯一**的出口是 [`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** 的串会自作主张补 //! 一条实测(别把它当 bug 修掉):reqwest 的 `Proxy::all` 对**没有 scheme** 的串会自作主张补
//! `http://` —— `127.0.0.1:7890` 是合法写法。所以用户少写 `http://` 不算「解析不了」;真正会失败的 //! `http://` —— `127.0.0.1:7890` 是合法写法。所以用户少写 `http://` 不算「解析不了」;真正会失败的
@@ -58,12 +71,18 @@
//! `client-proxy-system` feature ⇒ `system` 只认环境变量),PAC 脚本与需要认证(`Proxy-Authorization`) //! `client-proxy-system` feature ⇒ `system` 只认环境变量),PAC 脚本与需要认证(`Proxy-Authorization`)
//! 的代理同样没测。 //! 的代理同样没测。
use std::fmt;
use reqwest::ClientBuilder; use reqwest::ClientBuilder;
use reqwest::Url;
use super::error::{DownloadError, DownloadErrorKind}; use super::error::{DownloadError, DownloadErrorKind};
/// `download.proxy_mode` + `download.proxy_url` 的翻译结论(**全仓唯一**一份)。 /// `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 { pub enum ProxySetting {
/// `none`(schema 出厂值):**真直连**,连环境变量里的代理也不读。 /// `none`(schema 出厂值):**真直连**,连环境变量里的代理也不读。
#[default] #[default]
@@ -73,10 +92,21 @@ pub enum ProxySetting {
/// `manual`:整条流量走这个地址。 /// `manual`:整条流量走这个地址。
Manual { Manual {
/// `download.proxy_url` 的原串(已去前后空白;**未**校验,校验在 [`Self::apply`])。 /// `download.proxy_url` 的原串(已去前后空白;**未**校验,校验在 [`Self::apply`])。
/// **原串留着**:真发请求要用它;一切对外字符串都先过 [`redact_proxy_url`]。
url: String, 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 { impl ProxySetting {
/// 设置原文 → 代理三态(**唯一**翻译入口:两个键一起读,规则见模块头)。 /// 设置原文 → 代理三态(**唯一**翻译入口:两个键一起读,规则见模块头)。
/// ///
@@ -113,12 +143,26 @@ impl ProxySetting {
/// 一行「引擎实际拿到了什么」(`DownloadTuning::summary` 用:三档语义不同,只印 `as_str` /// 一行「引擎实际拿到了什么」(`DownloadTuning::summary` 用:三档语义不同,只印 `as_str`
/// 会把「跟随环境变量」与「某个具体地址」印成同一句话)。 /// 会把「跟随环境变量」与「某个具体地址」印成同一句话)。
///
/// 地址过 [`redact_proxy_url`]:这行会进 CLI 的 stderr(`download::announce`)、进而进 CI 日志。
#[must_use] #[must_use]
pub fn describe(&self) -> String { pub fn describe(&self) -> String {
match self { match self {
Self::Direct => "none(直连)".to_string(), Self::Direct => "none(直连)".to_string(),
Self::System => "system(跟随环境变量)".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<String> {
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 /// # Errors
/// ///
/// 只在 `manual` 档、且地址解析不出来时:`kind = InvalidRequest`,`message` 带上那个原串 /// 只在 `manual` 档、且地址用不了时:`kind = InvalidRequest`,`message` 带上(脱敏后的)原串与
/// (用户要能照着改)+ reqwest 的解析原因。**不 panic、不静默改成别的档**——回落由装配点决定, /// **能读**的原因。**不 panic、不静默改成别的档**——回落由装配点决定,见模块头。
/// 见模块头「非法地址不得穿透」。
pub fn apply(&self, builder: ClientBuilder) -> Result<ClientBuilder, DownloadError> { pub fn apply(&self, builder: ClientBuilder) -> Result<ClientBuilder, DownloadError> {
match self { match self {
Self::Direct => Ok(builder.no_proxy()), Self::Direct => Ok(builder.no_proxy()),
@@ -136,159 +179,100 @@ impl ProxySetting {
// 这一档要的正是后者。写成 `.proxy(Proxy::system())` 会更像「显式」,但 reqwest 0.12 // 这一档要的正是后者。写成 `.proxy(Proxy::system())` 会更像「显式」,但 reqwest 0.12
// 没有这个公开构造函数(`system` 只在 `auto_sys_proxy` 为真时内部生效)。 // 没有这个公开构造函数(`system` 只在 `auto_sys_proxy` 为真时内部生效)。
Self::System => Ok(builder), Self::System => Ok(builder),
Self::Manual { url } => { Self::Manual { url } => Ok(builder.proxy(manual_proxy(url)?)),
let proxy = reqwest::Proxy::all(url).map_err(|err| { }
}
}
/// **口令脱敏的唯一实现**:`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<reqwest::Proxy, DownloadError> {
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( DownloadError::new(
DownloadErrorKind::InvalidRequest, DownloadErrorKind::InvalidRequest,
format!("代理地址无法解析({url}):{err}"), format!("代理地址无法解析({shown}):{}", reason(&err)),
) )
})?; })?;
Ok(builder.proxy(proxy)) 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)] #[cfg(test)]
mod tests { #[path = "proxy/tests.rs"]
use super::super::config::{DownloadTuning, EngineConfig}; mod tests;
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()
);
}
}
+229
View File
@@ -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()
);
}
@@ -269,3 +269,45 @@ async fn an_unparseable_proxy_url_falls_back_to_direct_and_says_why() {
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed); assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
std::fs::remove_dir_all(&harness.dir).ok(); 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();
}
+13 -1
View File
@@ -80,6 +80,13 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
let region_value = text(env, "download.region"); let region_value = text(env, "download.region");
let mirror_value = text(env, "download.mirror_preference"); let mirror_value = text(env, "download.mirror_preference");
let proxy_value = text(env, "download.proxy_mode"); 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)") SettingGroupView::new("download", "下载与镜像(D7)")
.with(SettingRowView::choice( .with(SettingRowView::choice(
unlucky_core::download::config::KEY_CONCURRENCY, unlucky_core::download::config::KEY_CONCURRENCY,
@@ -149,10 +156,15 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
)) ))
// M295:这一行只在 manual 档生效;别的档下地址被**忽略**(值仍留在文件里),地址解析不了 // M295:这一行只在 manual 档生效;别的档下地址被**忽略**(值仍留在文件里),地址解析不了
// 时引擎回落直连并说明原因——三件事都写进文案,否则「填了没生效」又得靠用户猜。 // 时引擎回落直连并说明原因——三件事都写进文案,否则「填了没生效」又得靠用户猜。
// r1 复审 p2-2:`proxy_note` 是**运行时**结论(这一档真的用不了),独占一行接在静态说明后面
// ——「回落了却不说」等于让用户在以为走代理的情况下裸奔。
.with(SettingRowView::text( .with(SettingRowView::text(
"download.proxy_url", "download.proxy_url",
"HTTP 代理", "HTTP 代理",
"只在 manual 模式生效(其它模式忽略这段地址);manual 留空则用系统代理;地址写法不对时按直连处理,原值保留", &with_note(
"只在 manual 模式生效(其它模式忽略这段地址);manual 留空则用系统代理;地址写法不对时按直连处理,原值保留".to_string(),
proxy_note.as_deref(),
),
&text(env, "download.proxy_url"), &text(env, "download.proxy_url"),
)) ))
// M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把 // M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把
@@ -131,3 +131,70 @@ fn the_two_source_selection_keys_reach_the_tuning() {
assert_eq!(tuning.mirror_preference, MirrorPreference::Official); assert_eq!(tuning.mirror_preference, MirrorPreference::Official);
std::fs::remove_dir_all(&dir).ok(); 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();
}
+54 -3
View File
@@ -208,6 +208,47 @@ config/launcher.json
* **`reqwest` 会替用户补 `http://`**:`Proxy::all("127.0.0.1:7890")` 是合法写法(少写 scheme 不算 * **`reqwest` 会替用户补 `http://`**:`Proxy::all("127.0.0.1:7890")` 是合法写法(少写 scheme 不算
「解析不了」)。真正解析不了的只剩端口越界(`…:99999`)、方括号不闭合这一类——用例挑的是前者。 「解析不了」)。真正解析不了的只剩端口越界(`…: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` 打 `<redacted>`,`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. 守卫与变异证明(逐条只改实现侧,跑完即还原) ## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原)
| # | 变异(只改实现侧) | 守卫用例 | 结果 | | # | 变异(只改实现侧) | 守卫用例 | 结果 |
@@ -233,7 +274,11 @@ config/launcher.json
| M19(M295) | `ProxySetting::apply` 的 `System` 档改成 `.no_proxy()` | 同上 | 红(system 档不再跟随环境变量 ⇒ `AllSourcesFailed`) | | 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) | | 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) | | 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` 是完整字段 **M20 / M21 的一条附注**:直接把那两个字段**删掉**根本编译不过——两个宿主的 `RawTuning` 是完整字段
字面量(没有 `..RawTuning::default()`),编译器会报 `E0063 missing fields proxy_mode and proxy_url`。 字面量(没有 `..RawTuning::default()`),编译器会报 `E0063 missing fields proxy_mode and proxy_url`。
@@ -307,13 +352,19 @@ M193 时期这一组把「GitHub 加速前缀」的三态画成三行(`GitHub
没开 `client-proxy-system` feature ⇒ `system` 档只认环境变量(`HTTP_PROXY` 等)。所以「我在 没开 `client-proxy-system` feature ⇒ `system` 档只认环境变量(`HTTP_PROXY` 等)。所以「我在
Windows 设置里开了系统代理」这件事对 `system` 档**无效**,这是已知限制,不是本页的承诺; Windows 设置里开了系统代理」这件事对 `system` 档**无效**,这是已知限制,不是本页的承诺;
* PAC 脚本(`AutoConfigURL` / `PROXY.pac`)没测,代码里也没有任何 PAC 支持; * PAC 脚本(`AutoConfigURL` / `PROXY.pac`)没测,代码里也没有任何 PAC 支持;
* 需要认证的代理(`Proxy-Authorization` / `Proxy::basic_auth`)没测; * 需要认证的代理(`Proxy-Authorization` / `Proxy::basic_auth`)没测;userinfo 里的账号口令
* SOCKS 代理没测(reqwest 的 `socks` feature 本仓没开); 是否被 reqwest 转成 `Proxy-Authorization` 也没验(我们只保证**文案**里没有口令,见 §5.3);
* SOCKS 代理**不支持**(reqwest 的 `socks` feature 没开)。M295 r1 起它不再是「静默直连」:
`socks5://` 会被 scheme 白名单拦下有界错误并提示「本构建不支持 socks5 代理」(§5.3),但「能用
SOCKS」这件事仍然没有;
* HTTPS 目标的 `CONNECT` 隧道没测:夹具只讲明文 HTTP,`manual` 档对 https 目标的行为只在 * HTTPS 目标的 `CONNECT` 隧道没测:夹具只讲明文 HTTP,`manual` 档对 https 目标的行为只在
「`Proxy::all` 会拦下 https」这一层有依据; 「`Proxy::all` 会拦下 https」这一层有依据;
* 代理自身连不上/半死时被归到 `Network` 还是 `Timeout`(以及会不会被 `attempts_for` 重试)没测; * 代理自身连不上/半死时被归到 `Network` 还是 `Timeout`(以及会不会被 `attempts_for` 重试)没测;
* **没在真的代理软件上点过**(Clash / v2rayN / 公司代理),也没在「只靠 `HTTP_PROXY` 上网」的 * **没在真的代理软件上点过**(Clash / v2rayN / 公司代理),也没在「只靠 `HTTP_PROXY` 上网」的
环境里回归过 `none` 由「碰巧跟随环境变量」变成真直连这件事——那是一次真实的行为变化(见 §5.2)。 环境里回归过 `none` 由「碰巧跟随环境变量」变成真直连这件事——那是一次真实的行为变化(见 §5.2)。
* 「回落提示」只在 CLI 的真实二进制上端到端验过(`ull install --dry-run` 两行都不含口令);
**GUI 那一行只在单元层面验**(`group()` 产出的 `description` 文本),没在真窗口里看过它渲染、
折行、以及它与 424 列表单宽度的相容性(设置行的渲染在 `app-iced`,不在本页 scope)。
* **发布版 GUI 的「下载 JDK」页仍不认代理两键**(M295):那一处是独立装配点,直接用 * **发布版 GUI 的「下载 JDK」页仍不认代理两键**(M295):那一处是独立装配点,直接用
`EngineConfig::default()`(= `system`,跟随环境变量)。它与「并发 / 三段时间」是同一条已知缺口 `EngineConfig::default()`(= `system`,跟随环境变量)。它与「并发 / 三段时间」是同一条已知缺口
(`app-iced/…/java/downloads/engine.rs` 模块头的对照表),要接就得把那一处并进 `wanted(cache_root)`。 (`app-iced/…/java/downloads/engine.rs` 模块头的对照表),要接就得把那一处并进 `wanted(cache_root)`。