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:
1 parent
d584a2c42e
commit
c182d8d5c9
7 files changed
+584
-167
No files matched your search
@@ -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}");
|
||||||
|
}
|
||||||
+147
-163
@@ -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| {
|
|
||||||
DownloadError::new(
|
|
||||||
DownloadErrorKind::InvalidRequest,
|
|
||||||
format!("代理地址无法解析({url}):{err}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(builder.proxy(proxy))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **口令脱敏的唯一实现**:`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(
|
||||||
|
DownloadErrorKind::InvalidRequest,
|
||||||
|
format!("代理地址无法解析({shown}):{}", reason(&err)),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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)`。
|
||||||
Reference in new issue
Block a user