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

r2 复审退回的形态:`代理地址无法解析(http://127.0.0.1:99999):builder error`。第一版靠
`Proxy::all` 的报错(只有这一句),第二版靠 `url::ParseError` 的 Display(英文 `invalid port
number`,而且 reqwest 的宽口径会让「解析成功/失败」本身不可靠)——两种都还是把别人的原文当结论。

- `validate()` 自己判四类,各给一句中文:scheme 不支持 / 缺少主机名 / 端口(空、0、越界)/ 空地址;
  `manual_proxy` 先过它,再 `Proxy::all`。
- 残余情形(validate 放行但 reqwest 不认,例如 userinfo 有非法字符):中文结论在前,reqwest 原文
  只作为括号里的证据。
- 判据改成「文案里命中该类中文原因,且不含 `builder error` / `invalid port number`」,四类表驱动
  + 合法写法(含 `[::1]:8080` 与少写 scheme)不许被拒。
- 实测(真二进制,`ull install 1.20.1 --dry-run`,口令样本 PWMUSTNOTLEAK):
  `代理地址用不了(http://user:***@127.0.0.1:99999):端口 99999 不是 1–65535 之间的数字`
  `代理地址用不了(socks5://user:***@127.0.0.1:1080):本构建不支持 socks5 代理(只支持 http / https)`
  口令出现 0 次。
- 变异(本轮重做):关掉端口判定 ⇒ 红,且红的样子正是「地址写法不被接受(builder error:invalid
  port number)」——证明没有自己的判定时原文就会漏出来;关掉主机名判定 ⇒ 红;scheme 白名单写
  `if false` ⇒ 红;`redact_proxy_url` 返回原文 ⇒ 3 条红(口令泄漏)。见 docs §6 M23/M25/M26/M27。
- 门禁(含塔点名的整棵 `-p unlucky-core --lib`):914 passed / 0 failed;四 crate 测试 45 个
  binary、3206 passed / 0 failed;clippy `-D warnings`、`fmt --all --check`、
  `check --workspace --all-targets` 全 0。
This commit is contained in:
AskaEth committed 2026-11-13 11:47:21 +08:00
1 parent c182d8d5c9
commit 422f198c84
4 files changed
+142 -63

No files matched your search

+72 -32
View File
@@ -47,10 +47,13 @@
//!
//! # 非法值不得穿透(两类都走同一条路:有界错误 + 回落直连 + 告知)
//!
//! 1. **解析不了**(端口越界 `…:99999`、方括号不闭合…):[`ProxySetting::apply`] 返回
//! `InvalidRequest` + 带(脱敏后)原串与**能读**的原因,**不 panic**;
//! 2. **解析得了但本构建用不上**(`socks5://` 等:reqwest 没开 `socks` feature,而
//! `Proxy::all` 对它们照样返回 `Ok`):同样是有界错误,文案直接点名 scheme。
//! 1. **用不了**:由 `validate` **自己**判四类——空地址 / scheme 不支持(`socks5://` 等)/
//! 缺少主机名 / 端口不合法(空、`0`、越界)。每类各一句中文原因,**不读** reqwest 的
//! `builder error`、也不匹配 `url` 的英文文案(那是「摊原文」,等于没解释);
//! 2. **reqwest 层残余的拒绝**(`validate` 放行但它不认,例如 userinfo 里有非法字符):中文结论在
//! 前,reqwest 原文只作为括号里的证据。
//!
//! 两种情况都是 `InvalidRequest` + 带(脱敏后)原串的可读文案,**不 panic**。
//!
//! 装配点([`super::engine::DownloadEngine::new`])的处置是「回落直连 + 把原因留在引擎上」
//! (`DownloadEngine::proxy_problem`):一个设置字符串不该让用户连文件都下不了,但也不许它悄悄变成
@@ -58,8 +61,7 @@
//! [`ProxySetting::problem`] 的结论画在「HTTP 代理」那一行(两个宿主都调同一个判定,不各写一份)。
//!
//! 一条实测(别把它当 bug 修掉):reqwest 的 `Proxy::all` 对**没有 scheme** 的串会自作主张补
//! `http://` —— `127.0.0.1:7890` 是合法写法。所以用户少写 `http://` 不算「解析不了」;真正会失败的
//! 是端口越界(`…:99999`)、方括号不闭合这一类。
//! `http://` —— `127.0.0.1:7890` 是合法写法。所以「宽口径」只补到「少写 scheme」为止,判据自己算。
//!
//! # 不落盘
//!
@@ -74,7 +76,6 @@
use std::fmt;
use reqwest::ClientBuilder;
use reqwest::Url;
use super::error::{DownloadError, DownloadErrorKind};
@@ -217,44 +218,83 @@ pub fn redact_proxy_url(raw: &str) -> String {
/// `manual` 档的解析与判定(`apply` 与 `problem` 的**同一条**路)。
///
/// 顺序有讲究:先自己按 reqwest 的宽口径(少写 scheme 也认)`Url::parse` 一遍,理由是两件事——
/// ① 拿 scheme 做白名单(`Proxy::all` 对 `socks5://` 也返回 `Ok`,见模块头「非法值不得穿透」第 2 条);
/// ② 拿**能读**的错因(`url::ParseError` 的 Display 是 `invalid port number` 这种,
/// 而 `Proxy::all` 的 Display 只有一句 `builder error`)。
/// 顺序:**先自己判**([`validate`]:空 / scheme / host / 端口四类各给一句中文原因),再用
/// `reqwest::Proxy::all` 真的造代理。为什么不靠 `Proxy::all` 的报错:它的 Display 只有一句
/// `builder error`(原因挂在 `source()` 上,甚至可能是空的),把原文摊给用户等于没解释;
/// 为什么不靠 `url::ParseError` 的 Display:那是英文(`invalid port number`),而且 reqwest 的
/// 宽口径(给少写 scheme 的串补 `http://`)会让「解析成功/失败」本身就不可靠——端口越界正是
/// 撞在这上面(r2 复审要求:四类各给一句,不许摊原文)。
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| {
validate(url).map_err(|why| {
DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!("代理地址无法解析({shown}):{}", reason(&err)),
format!("代理地址用不了({shown}):{why}"),
)
})?;
if !matches!(target.scheme(), "http" | "https") {
return Err(DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!(
"本构建不支持 {} 代理(只支持 http / https):{shown}",
target.scheme()
),
));
}
// 残余失败(validate 放行、但 reqwest 不认,例如 userinfo 里有非法字符):中文结论在前,
// reqwest 的原文只作为括号里的**证据**,不当结论。
reqwest::Proxy::all(url).map_err(|err| {
DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!("代理地址无法解析({shown}):{}", reason(&err)),
format!(
"代理地址用不了({shown}):地址写法不被接受({})",
reason(&err)
),
)
})
}
/// 代理地址的**四类**判定:空 / scheme 不支持 / 缺少主机名 / 端口不合法。
///
/// 每条都**自己算**(不解析 reqwest 的错误、也不匹配英文文案),所以判定与 reqwest/url 的版本、
/// 宽口径、错误措辞无关。这里只判「能不能拿去连」,不做归一化——通过之后原串仍会被
/// `reqwest::Proxy::all` 拿去用(`127.0.0.1:7890` 这种少写 scheme 的写法是合法的)。
fn validate(raw: &str) -> Result<(), String> {
let text = raw.trim();
if text.is_empty() {
return Err("地址是空的(manual 档要填 `http://host:port`)".to_string());
}
let (scheme, rest) = match text.split_once("://") {
Some((scheme, rest)) => (Some(scheme.to_ascii_lowercase()), rest),
None => (None, text),
};
if let Some(scheme) = &scheme {
if !matches!(scheme.as_str(), "http" | "https") {
return Err(format!("本构建不支持 {scheme} 代理(只支持 http / https)"));
}
}
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
// 最后一个 `@` 之后才是 host[:port](userinfo 里的 `@` `:` 不参与)。
let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
let (host, port) = match host_port.strip_prefix('[') {
// IPv6 字面量:`[::1]:8080`
Some(after_open) => match after_open.split_once(']') {
Some((host, after)) => (host, after.strip_prefix(':')),
None => return Err("IPv6 地址的方括号没有闭合".to_string()),
},
None => match host_port.rsplit_once(':') {
Some((host, port)) => (host, Some(port)),
None => (host_port, None),
},
};
if host.is_empty() {
return Err("缺少主机名(`host` 是空的)".to_string());
}
if let Some(port) = port {
if port.is_empty() {
return Err("端口是空的(`host:` 后面没有数字)".to_string());
}
match port.parse::<u16>() {
Ok(0) => return Err("端口 0 不是有效端口(要 1–65535)".to_string()),
Ok(_) => {}
Err(_) => return Err(format!("端口 {port} 不是 1–65535 之间的数字")),
}
}
Ok(())
}
/// 把错误的 `source` 链拼成**一句能读的原因**(r1 复审 p3-3)。
///
/// 为什么不能只用 `Display`:`reqwest::Error` 的 Display 是 `builder error`(kind 的名字),真正的
+49 -19
View File
@@ -149,14 +149,16 @@ fn a_proxy_url_without_a_password_is_left_byte_identical() {
);
}
/// **两类「用不了」都走有界错误,且原因能读**(r1 复审 p3-1 / p3-3)。
/// **四类「用不了」都走有界错误,而且每类各有一句中文原因**(r1 p3-1/p3-3 + r2 复审必修)。
///
/// * `socks5://`:`Proxy::all` 会返回 `Ok`,但本构建(reqwest 没开 `socks` feature)根本用不了它;
/// 不拦的话用户只看到「所有候选源都失败了」,把原因推给源。现在点名 scheme。
/// * 端口越界:原因串必须是 `invalid port number` 这种,不许只有一句 `builder error`。
/// 判据落在**最终文案**上:`problem()` 里出现该类的中文原因,且**没有**把 reqwest/url 的原文
/// 当结论(`builder error`、`invalid port number` 都不许出现在文案里)。
///
/// 变异:删掉 scheme 白名单 ⇒ 第一条红(`problem()` 变 `None`);把 `reason()` 换成只印 `Display`
/// ⇒ 第二条红(文案里没有 `invalid port number`)。
/// 为什么强调「自己算」:reqwest 的 `Proxy::all` 对端口越界只会回一句 `builder error`,而
/// `url::ParseError` 的 Display 是英文——两种「摊原文」都不算解释(r2 复审就是这么退回的)。
///
/// 变异:① 删掉 `validate` 里的端口判定 ⇒ 表格第一行红(落到 reqwest 那句 `builder error`);
/// ② 删掉 scheme 白名单 ⇒ 第二行红(`problem()` 变 `None`);③ 删掉主机名为空的判定 ⇒ 第三行红。
#[test]
fn unsupported_schemes_and_bad_ports_explain_themselves() {
let socks = ProxySetting::Manual {
@@ -171,24 +173,52 @@ fn unsupported_schemes_and_bad_ports_explain_themselves() {
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}");
// 四类各一句(表驱动):每行都断「命中该类的中文原因」,并断**没有**摊英文原文。
let cases: [(&str, &str); 5] = [
("http://127.0.0.1:99999", "端口 99999 不是 1–65535"),
("http://127.0.0.1:0", "端口 0 不是有效端口"),
("http://127.0.0.1:", "端口是空的"),
("http://:7890", "缺少主机名"),
("http://", "缺少主机名"),
];
for (url, expected) in cases {
let problem = ProxySetting::Manual {
url: url.to_string(),
}
.problem()
.unwrap_or_else(|| panic!("{url} 必须被判为用不了"));
assert!(problem.contains(expected), "{url} ⇒ {problem}");
assert!(
!problem.contains("builder error") && !problem.contains("invalid port number"),
"{url}:不许把 reqwest / url 的原文当结论:{problem}"
);
}
// 空地址:`from_raw` 会把它翻成 `System`(manual + 留空 = 用系统代理),所以这种 `Manual` 只能
// 手拼出来;手拼出来时同样有一句原因,不会静默通过。
assert!(ProxySetting::Manual {
url: " ".to_string()
}
.problem()
.is_some_and(|problem| problem.contains("地址是空的")));
// 正常档与合法地址没有问题(否则 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()
for ok in [
"http://127.0.0.1:7890",
"127.0.0.1:7890",
"https://proxy.example:8443/v1",
"http://[::1]:8080",
] {
assert!(
ProxySetting::Manual {
url: ok.to_string()
}
.problem()
.is_none(),
"{ok} 是合法写法,不该被拒"
);
}
.problem()
.is_none());
}
/// **两个默认值故意不同**(模块头那段的守卫):没读设置的子系统保持 M295 之前的行为,