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:
1 parent
c182d8d5c9
commit
422f198c84
4 files changed
+142
-63
No files matched your search
@@ -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 的名字),真正的
|
||||
|
||||
@@ -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 之前的行为,
|
||||
|
||||
@@ -253,7 +253,9 @@ async fn an_unparseable_proxy_url_falls_back_to_direct_and_says_why() {
|
||||
.proxy_problem()
|
||||
.expect("地址解析不了必须留下原因(宿主靠它告诉用户)");
|
||||
assert!(problem.contains("http://127.0.0.1:99999"), "{problem}");
|
||||
assert!(problem.contains("代理地址无法解析"), "{problem}");
|
||||
assert!(problem.contains("代理地址用不了"), "{problem}");
|
||||
assert!(problem.contains("端口 99999"), "原因要能读:{problem}");
|
||||
assert!(!problem.contains("builder error"), "{problem}");
|
||||
assert_eq!(
|
||||
harness.engine.config().proxy,
|
||||
broken,
|
||||
|
||||
Reference in new issue
Block a user