diff --git a/crates/cli/src/download.rs b/crates/cli/src/download.rs index 7c22fcd..ea188e8 100644 --- a/crates/cli/src/download.rs +++ b/crates/cli/src/download.rs @@ -31,7 +31,8 @@ use std::sync::Arc; use unlucky_core::download::config::{ DownloadTuning, RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, - KEY_MIRROR_PREFERENCE, KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR, + KEY_MIRROR_PREFERENCE, KEY_PROXY_MODE, KEY_PROXY_URL, KEY_REGION, KEY_RESPONSE_TIMEOUT, + KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR, }; use unlucky_core::download::{shared, DownloadEngine}; use unlucky_core::setting::keys::launcher_schema; @@ -39,7 +40,7 @@ use unlucky_core::setting::store::SettingStore; use crate::context::Ctx; -/// 从 CLI 自己的 setting store 读下载调优(`launcher.json` 的八个 `download.*` 键)。 +/// 从 CLI 自己的 setting store 读下载调优(`launcher.json` 的十个 `download.*` 键)。 /// /// 读的是 `ctx.launcher_config()`——与 `ull config set download.concurrency` 的落点同一份文件 /// (设置键的落点分流见 [`Ctx::setting_path_for`])。gh-proxy 空串 = 跟随内置默认,**不是**关。 @@ -52,6 +53,9 @@ use crate::context::Ctx; /// [`RawTuning`]**——那两个是枚举,翻译层是 core 的 [`DownloadTuning::with_source_selection`] /// (未知值回落 `auto`)。CLI 与 presenter 走的是同一个入口,别在宿主里再写一份字符串匹配: /// 两份口径就是「GUI 认、CLI 不认」的下一次复发。 +/// +/// M295:代理两键(`download.proxy_mode` / `proxy_url`)读法与 presenter 的 `tuning_of` 逐行相同 +/// ——同一个 `RawTuning`、同一个 `ProxySetting::from_raw`,CLI 侧**零**字符串匹配。 #[must_use] pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning { let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); @@ -67,6 +71,8 @@ pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning { throughput_floor_kib: doc.effective_i64(KEY_THROUGHPUT_FLOOR), stall_timeout_secs: doc.effective_i64(KEY_CHUNK_STALL), response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT), + proxy_mode: doc.effective_str(KEY_PROXY_MODE), + proxy_url: doc.effective_str(KEY_PROXY_URL), }) .with_source_selection( doc.effective_str(KEY_REGION), @@ -99,7 +105,13 @@ pub(crate) fn engine(ctx: &Ctx) -> Arc { if let Some(line) = announce(&tuning) { eprintln!("{line}"); } - shared::engine_with_tuning(ctx.layout().cache(), tuning) + let engine = shared::engine_with_tuning(ctx.layout().cache(), tuning); + // 代理那一档没生效时(`manual` 的地址解析不了 ⇒ 引擎回落直连)必须说出来:静默回落等于把 + // 「设置项说假话」换成「设置项不说实话」,用户只会觉得「填了代理还是连不上」。 + if let Some(problem) = engine.proxy_problem() { + eprintln!("下载代理未生效,本次按直连:{problem}"); + } + engine } #[cfg(test)] diff --git a/crates/cli/src/download/tests.rs b/crates/cli/src/download/tests.rs index 33bd0a6..6fd979b 100644 --- a/crates/cli/src/download/tests.rs +++ b/crates/cli/src/download/tests.rs @@ -389,3 +389,7 @@ fn the_source_selection_settings_reach_the_cli_engine() { assert!(line.contains("地区=cn"), "{line}"); assert!(line.contains("镜像偏好=auto"), "{line}"); } + +/// 代理两键的 CLI 侧守门(M295):单开一个文件——本文件已贴着 400 行硬线。 +#[path = "tests/proxy.rs"] +mod proxy; diff --git a/crates/cli/src/download/tests/proxy.rs b/crates/cli/src/download/tests/proxy.rs new file mode 100644 index 0000000..9e34dce --- /dev/null +++ b/crates/cli/src/download/tests/proxy.rs @@ -0,0 +1,110 @@ +//! **代理两键的 CLI 侧守门(M295)**:`download.proxy_mode` / `download.proxy_url` 从 CLI 自己的 +//! `launcher.json` 进四条命令的生产装配点,终端那行生效值也含代理档。 +//! +//! 从 `tests.rs` 拆出只为守 400 行硬线(与 core 的 `engine_mock/*.rs` 同一手法)。 +//! +//! 抓得住:① `download::tuning` 不读 `KEY_PROXY_MODE` / `KEY_PROXY_URL` ⇒ 三档全退回 `Direct`, +//! 前两条红;② `summary()` 少印代理 ⇒ 那行断言红;③ 非法地址让装配 panic ⇒ 最后一段红; +//! ④ 键名常量与 schema 登记脱钩 ⇒ 末尾那句红。 +//! +//! 抓不住:请求是不是**真的**经由代理(core 的 `tests/download/engine_mock/proxy.rs`:本地假代理 + +//! `.invalid` 目标 + 绝对 URI 请求行);也抓不住真实的系统代理 / PAC / 需要认证的代理 +//! (见 `docs/download-settings.md` §7)。 + +use super::*; + +#[test] +fn the_proxy_settings_reach_the_cli_engine_and_the_console_line() { + use unlucky_core::download::config::{KEY_PROXY_MODE, KEY_PROXY_URL}; + use unlucky_core::download::proxy::ProxySetting; + + let dir = TempDir::new("proxy"); + let ctx = ctx_at(&dir); + + // manual:地址进引擎配置,终端那行写的是同一档。 + write_launcher_config( + &ctx, + json!({ "download": { "proxy_mode": "manual", "proxy_url": " http://127.0.0.1:7890 " } }), + ); + let engine = crate::commands::install::build_engine(&ctx); + assert_eq!( + engine.config().proxy, + ProxySetting::Manual { + url: "http://127.0.0.1:7890".to_string() + }, + "manual 档必须落到引擎配置上(去空白)" + ); + let line = + crate::download::announce(&crate::download::tuning(&ctx)).expect("改过设置 ⇒ 该有一行"); + assert!( + line.contains("代理=manual(http://127.0.0.1:7890)"), + "{line}" + ); + + // system + 一个残留地址:地址被忽略,档位是 system(终端那行也要说清是「跟随环境变量」)。 + write_launcher_config( + &ctx, + json!({ "download": { "proxy_mode": "system", "proxy_url": "http://127.0.0.1:7890" } }), + ); + assert_eq!( + crate::commands::install::build_engine(&ctx).config().proxy, + ProxySetting::System + ); + let line = crate::download::announce(&crate::download::tuning(&ctx)).expect("该有一行"); + assert!(line.contains("代理=system(跟随环境变量)"), "{line}"); + + // 非法地址:不 panic;引擎给出可读原因,配置字段仍是用户写的值(回落是运行时行为)。 + write_launcher_config( + &ctx, + json!({ "download": { "proxy_mode": "manual", "proxy_url": "http://127.0.0.1:99999" } }), + ); + let engine = crate::commands::install::build_engine(&ctx); + let problem = engine + .proxy_problem() + .expect("地址解析不了要留下原因(CLI 的那行 stderr 靠它)"); + assert!(problem.contains("http://127.0.0.1:99999"), "{problem}"); + assert_eq!( + engine.config().proxy, + ProxySetting::Manual { + url: "http://127.0.0.1:99999".to_string() + } + ); + + // 两个键名常量必须真的登记在 schema 里(写错 = 「用户设了没生效」的静默退化)。 + let schema = launcher_schema(); + for key in [KEY_PROXY_MODE, KEY_PROXY_URL] { + 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}"); +} diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs index 3a1fb1a..6040195 100644 --- a/crates/core/src/download/config.rs +++ b/crates/core/src/download/config.rs @@ -21,6 +21,7 @@ use std::time::Duration; use super::backoff::BackoffPolicy; use super::chunked::ChunkedConfig; +use super::proxy::ProxySetting; use super::stall::{self, Deadlines}; use super::types::ResumePolicy; @@ -96,6 +97,12 @@ pub const KEY_GH_PROXY: &str = "download.gh_proxy"; pub const KEY_REGION: &str = "download.region"; /// 「镜像偏好」键(`auto` / `official` / `mirror`)。读法与优先级同 [`KEY_REGION`]。 pub const KEY_MIRROR_PREFERENCE: &str = "download.mirror_preference"; +/// 「代理模式」键(`none` / `system` / `manual`)。读它的是宿主,翻译成 [`ProxySetting`] +/// (三档语义、`proxy_url` 在非 manual 档的处置、非法地址怎么回落,**全写在** [`super::proxy`] 的 +/// 模块头——别在宿主里再解释一次字符串)。 +pub const KEY_PROXY_MODE: &str = "download.proxy_mode"; +/// 「代理地址」键(**只在 `manual` 档生效**,其它档忽略,见 [`KEY_PROXY_MODE`])。 +pub const KEY_PROXY_URL: &str = "download.proxy_url"; /// 引擎配置。 #[derive(Debug, Clone)] @@ -146,6 +153,16 @@ pub struct EngineConfig { pub chunked: ChunkedConfig, /// 默认续传策略(请求级 `Never` 优先级更高)。 pub resume: ResumePolicy, + /// 代理三态(`download.proxy_mode` / `download.proxy_url` 的翻译结论,唯一翻译层在 + /// [`super::proxy`])。 + /// + /// **默认 [`ProxySetting::System`],与 `download.proxy_mode` 的 schema 出厂值(`none`)故意不同**: + /// 这一档是「这个子系统**没读**设置」时的行为(`auth` 的登录、发布版 GUI 的 JDK 下载页、地区探测 + /// 都直接 `EngineConfig::default()`),保持 M295 之前「跟随环境变量代理」才不会静默改掉它们的 + /// 网络路径。读了设置的装配点(`shared::build` / `engine_with`)由 `DownloadTuning` 显式盖上去, + /// 那一路的出厂值是 `none`(真直连,与设置页画的那颗 chip 一致)。两处默认为什么必须不同, + /// 见 [`super::proxy`] 模块头。 + pub proxy: ProxySetting, } impl Default for EngineConfig { @@ -166,6 +183,7 @@ impl Default for EngineConfig { backoff: BackoffPolicy::default(), chunked: ChunkedConfig::default(), resume: ResumePolicy::Conservative, + proxy: ProxySetting::System, } } } diff --git a/crates/core/src/download/config/tuning.rs b/crates/core/src/download/config/tuning.rs index 1c9c65e..9b0e0fd 100644 --- a/crates/core/src/download/config/tuning.rs +++ b/crates/core/src/download/config/tuning.rs @@ -11,6 +11,7 @@ use std::time::Duration; use super::super::preference::{self, MirrorPreference, RegionPreference}; +use super::super::proxy::ProxySetting; use super::super::region::RegionProfile; use super::super::stall; use super::{ @@ -46,6 +47,9 @@ pub struct DownloadTuning { pub region: RegionPreference, /// 显式的「镜像偏好」覆盖(`download.mirror_preference`;`Auto` = 跟着 `region` 走)。 pub mirror_preference: MirrorPreference, + /// 代理三态(`download.proxy_mode` + `download.proxy_url`)。出厂 [`ProxySetting::Direct`]: + /// 与 schema 的 `none` 同值(三档语义与回落见 `core::download::proxy` 模块头)。 + pub proxy: ProxySetting, } impl Default for DownloadTuning { @@ -59,6 +63,7 @@ impl Default for DownloadTuning { min_avg_throughput: stall::MIN_AVG_THROUGHPUT, region: RegionPreference::Auto, mirror_preference: MirrorPreference::Auto, + proxy: ProxySetting::Direct, } } } @@ -82,6 +87,14 @@ pub struct RawTuning<'a> { pub stall_timeout_secs: Option, /// [`KEY_RESPONSE_TIMEOUT`](super::KEY_RESPONSE_TIMEOUT)(秒)。 pub response_timeout_secs: Option, + /// [`KEY_PROXY_MODE`](super::KEY_PROXY_MODE)(`none` / `system` / `manual`)。 + /// + /// 代理两键**进这个结构体**(不像 `region` / `mirror_preference` 那样另开一个 `with_*` 入口): + /// 它们是「字符串原文 + 一个客户端开关」,与 `gh_proxy` 同形;而 `region` 那一对当初另开入口 + /// 是因为给本结构体加字段会波及当时不在 scope 的 CLI(M294 的权宜),不是为了风格。 + pub proxy_mode: Option<&'a str>, + /// [`KEY_PROXY_URL`](super::KEY_PROXY_URL)(**只在 `manual` 档被读**,其它档忽略)。 + pub proxy_url: Option<&'a str>, } /// `min..=max` 之间的秒数 → `Duration`(负数 / `0` / 越界一律 `None`,由调用方回落出厂值)。 @@ -101,6 +114,10 @@ impl DownloadTuning { /// /// 三个超时键的 `0` 也走「回落默认」而不是「夹到 1」:`0` 秒的窗口等于「立刻判失速」、 /// `0` 字节/秒的吞吐下限是除零——两种都不是用户想要的东西,静默回落到出厂值是唯一安全的读法。 + /// + /// 代理两键在这里翻译([`ProxySetting::from_raw`]):未知模式 ⇒ `none`、`manual` + 空地址 ⇒ + /// `system`、地址**不在这里校验**(解析失败由 `build_client` 报有界错误,见 + /// `core::download::proxy` 模块头)。 #[must_use] pub fn from_raw(raw: RawTuning<'_>) -> Self { let default = Self::default(); @@ -141,6 +158,7 @@ impl DownloadTuning { // `from_raw` 恒给 `Auto`,即「不发言」。 region: RegionPreference::Auto, mirror_preference: MirrorPreference::Auto, + proxy: ProxySetting::from_raw(raw.proxy_mode, raw.proxy_url), } } @@ -169,6 +187,10 @@ impl DownloadTuning { cfg.response_timeout = self.response_timeout; cfg.chunk_stall_timeout = self.chunk_stall_timeout; cfg.min_avg_throughput = self.min_avg_throughput; + // 代理必须在这里盖:`EngineConfig::default()` 是 `System`(「没读设置」的档),而 + // `DownloadTuning::default()` 是 `Direct`(settings 的 `none`)——不盖就是「设置页说 none、 + // 引擎偷偷跟随环境变量」。 + cfg.proxy = self.proxy.clone(); cfg } @@ -181,6 +203,9 @@ impl DownloadTuning { /// `download::tuning` 已经读了它们,这一行若不含就等于「打了一行说生效值、却少说两项」。两个值都用 /// [`RegionPreference::as_str`] / [`MirrorPreference::as_str`],与 schema 的取值逐字相同。 /// + /// M295:`代理` 这一档用 [`ProxySetting::describe`](不是 `as_str`):`none` 与 `system` 行为 + /// 相反,只印一个词分不出「跟随环境变量」与「某个具体地址」。 + /// /// (`crates/cli/src/download.rs::tuning` 是唯一读者。) #[must_use] pub fn summary(&self) -> String { @@ -190,7 +215,7 @@ impl DownloadTuning { GhProxyOverride::Prefix(prefix) => prefix.clone(), }; format!( - "并发={} 单源尝试={} 吞吐下限={}KiB/s 无进展窗口={}s 单次请求上界={}s 地区={} 镜像偏好={} gh_proxy={gh_proxy}", + "并发={} 单源尝试={} 吞吐下限={}KiB/s 无进展窗口={}s 单次请求上界={}s 地区={} 镜像偏好={} 代理={} gh_proxy={gh_proxy}", self.max_concurrency, self.max_attempts_per_source, self.min_avg_throughput / 1024, @@ -198,6 +223,7 @@ impl DownloadTuning { self.response_timeout.as_secs(), self.region.as_str(), self.mirror_preference.as_str(), + self.proxy.describe(), ) } diff --git a/crates/core/src/download/engine.rs b/crates/core/src/download/engine.rs index 02ae5eb..cb9d7a9 100644 --- a/crates/core/src/download/engine.rs +++ b/crates/core/src/download/engine.rs @@ -28,6 +28,7 @@ use super::health::HealthRegistry; use super::http; use super::mirrors::MirrorTable; use super::progress::{ProgressEmitter, ProgressEvent}; +use super::proxy::ProxySetting; use super::region::RegionProfile; use super::stall::{StallReport, StallStage}; mod finish; @@ -53,6 +54,8 @@ pub(crate) struct AttemptOk { #[derive(Debug)] pub struct DownloadEngine { pub(crate) http: reqwest::Client, + /// 代理解析失败的原因(`None` = `cfg.proxy` 那一档原样生效)。见 [`Self::proxy_problem`]。 + pub(crate) proxy_problem: Option, pub(crate) cfg: EngineConfig, pub(crate) progress: ProgressEmitter, pub(crate) health: Arc, @@ -73,7 +76,18 @@ impl DownloadEngine { health: Arc, cache: Arc, ) -> Self { - let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new()); + // 代理地址解析不了(手改文件写坏了 `proxy_url`)⇒ 回落到**直连**重建:不能用 + // `reqwest::Client::new()` 兜(那会跟随环境变量代理,「回落」就成了不确定行为,还会顺手 + // 丢掉 UA / 超时 / 重定向策略)。原因留在引擎上,宿主打印出来([`Self::proxy_problem`])。 + let (http, proxy_problem) = match http::build_client(&cfg) { + Ok(client) => (client, None), + Err(err) => { + let mut direct = cfg.clone(); + direct.proxy = ProxySetting::Direct; + let client = http::build_client(&direct).unwrap_or_else(|_| reqwest::Client::new()); + (client, Some(err.message)) + } + }; // 硬上限夹取(M193):手改的 launcher.json / CLI 自拼的 cfg 都可能写着 9999,在唯一构造点再夹一次。 let permits = cfg.max_concurrency.clamp(1, MAX_CONCURRENCY); let sem = Arc::new(Semaphore::new(permits)); @@ -82,6 +96,7 @@ impl DownloadEngine { super::shared::record_build(cache.root()); Self { http, + proxy_problem, cfg, progress: ProgressEmitter::silent(), health, diff --git a/crates/core/src/download/engine/inspect.rs b/crates/core/src/download/engine/inspect.rs index 2cc9756..5a7a8b2 100644 --- a/crates/core/src/download/engine/inspect.rs +++ b/crates/core/src/download/engine/inspect.rs @@ -31,6 +31,17 @@ impl DownloadEngine { &self.http } + /// 代理这一档**没生效**的原因(`None` = 按 `config().proxy` 生效)。 + /// + /// 只有一种情况会给 `Some`:`manual` 的地址解析不出来 ⇒ 客户端回落**直连**(见 + /// [`DownloadEngine::new`]),而 `config().proxy` 仍是用户写的 `manual` —— 配置不撒谎,实际行为 + /// 由这个字段说明。宿主该把它打给用户(CLI 的 `download::engine` 打一行 stderr),否则「填了代理 + /// 但没走代理」又成了没法解释的半真话。 + #[must_use] + pub fn proxy_problem(&self) -> Option<&str> { + self.proxy_problem.as_deref() + } + /// 当前地区档案(设置页展示)。 #[must_use] pub fn region(&self) -> &RegionProfile { diff --git a/crates/core/src/download/http.rs b/crates/core/src/download/http.rs index 8dc7b52..412b731 100644 --- a/crates/core/src/download/http.rs +++ b/crates/core/src/download/http.rs @@ -40,25 +40,29 @@ pub const USER_AGENT: &str = concat!( " (+https://github.com/AskaEth/Unlucky-Launcher)" ); -/// 构造共享的 `reqwest::Client`(连接池、超时、重定向策略一次定好)。 +/// 构造共享的 `reqwest::Client`(连接池、超时、重定向策略、代理一次定好)。 +/// +/// **代理由配置决定,不在这里解释字符串**:`cfg.proxy` 是 `download.proxy_mode` / `proxy_url` 的 +/// 翻译结论(唯一翻译层是 [`super::proxy`]),三档各自落到 `.no_proxy()` / 什么都不做 / +/// `.proxy(Proxy::all(url))`。地址解析不了时返回**有界错误**(不 panic)——装配点 +/// (`DownloadEngine::new`)据此回落直连并把原因留在引擎上,见 [`super::proxy`] 模块头。 pub fn build_client(cfg: &EngineConfig) -> Result { let mut headers = HeaderMap::new(); headers.insert( ACCEPT_ENCODING, reqwest::header::HeaderValue::from_static("identity"), ); - reqwest::Client::builder() + let builder = reqwest::Client::builder() .user_agent(cfg.user_agent.clone()) .connect_timeout(cfg.connect_timeout) .redirect(Policy::limited(MAX_REDIRECTS)) - .default_headers(headers) - .build() - .map_err(|err| { - DownloadError::new( - DownloadErrorKind::Network, - format!("构造 HTTP 客户端失败:{err}"), - ) - }) + .default_headers(headers); + cfg.proxy.apply(builder)?.build().map_err(|err| { + DownloadError::new( + DownloadErrorKind::Network, + format!("构造 HTTP 客户端失败:{err}"), + ) + }) } /// **每一条 `send()` 的唯一上界构造点**:`transfer.rs` 的单连接请求、`chunked.rs` 的分块请求、 diff --git a/crates/core/src/download/mod.rs b/crates/core/src/download/mod.rs index 608f9f4..558cb24 100644 --- a/crates/core/src/download/mod.rs +++ b/crates/core/src/download/mod.rs @@ -61,6 +61,7 @@ pub mod jdk_sources; pub mod mirrors; pub mod preference; pub mod progress; +pub mod proxy; pub mod region; pub mod region_system; pub mod resume; @@ -95,6 +96,7 @@ pub use self::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable}; pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable, PrefixRule}; pub use self::preference::{MirrorPreference, RegionPreference}; pub use self::progress::{ProgressEmitter, ProgressEvent, ProgressThrottle}; +pub use self::proxy::ProxySetting; pub use self::region::{RegionMode, RegionProfile, RegionTrigger, Signals}; pub use self::region_system::detect_signals; pub use self::resume::{ChunkRecord, RestartReason, ResumeDecision, ResumeMeta}; diff --git a/crates/core/src/download/proxy.rs b/crates/core/src/download/proxy.rs new file mode 100644 index 0000000..27324e3 --- /dev/null +++ b/crates/core/src/download/proxy.rs @@ -0,0 +1,318 @@ +//! `download.proxy_mode` / `download.proxy_url` → reqwest 客户端的代理三态(M295)。 +//! +//! # 现状(M295 之前) +//! +//! `grep -rn "proxy_mode\|proxy_url\|ProxyMode" crates/core/src --include=*.rs | grep -v setting/keys` +//! **零命中**:两个键在引擎侧零消费者。`http::build_client` 当时只设 UA / 连接超时 / 重定向 / +//! 默认头,**从不 `.proxy()`**,于是设置页画着的「直连 / 系统代理 / 手动代理」三档 chip 与那个 +//! 地址文本框,对任何一次下载都不生效。`system` 那一档「碰巧能用」只是因为 reqwest 自己会读进程 +//! 环境变量(`HTTP_PROXY` 等),不是这段代码做的——这也是本模块必须把它写成**显式一档**的理由: +//! 三档都要是这段代码的判决,而不是「碰巧」。 +//! +//! # 三档语义(全仓唯一一处翻译) +//! +//! | `download.proxy_mode` | 翻译成 | 落到 client 上 | 含义 | +//! |---|---|---|---| +//! | `none`(schema 出厂值,键不存在也走这档) | [`ProxySetting::Direct`] | `.no_proxy()` | 什么都不走:连进程环境里的 `HTTP_PROXY` 也不读 | +//! | `system` | [`ProxySetting::System`] | **不调任何代理 API** | 跟随进程环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`)= reqwest 的默认行为,也是 M295 之前所有下载的实际行为 | +//! | `manual` | [`ProxySetting::Manual`] | `.proxy(Proxy::all(url))` | 整条流量走 `proxy_url`(http 与 https 目标都经它;`Proxy::all` 会顺手关掉「自动读系统代理」) | +//! +//! 未知值 / 空串 ⇒ `none`(与 schema 出厂值一致:静默回落、不报错、不中断下载,口径与 +//! `preference` 那两个枚举键相同)。 +//! +//! **默认值为什么要分两处**(`EngineConfig::default()` 是 `System`、`DownloadTuning::default()` +//! 是 `Direct`):`EngineConfig::default()` 是「这个子系统**没读**设置」的档(`auth` 的登录、 +//! 发布版 GUI 的 JDK 下载页、地区探测都直接用它),保持 M295 之前的行为(跟随环境变量)才不算 +//! 静默改网络路径;而读了设置的装配点(`shared::build` / `engine_with`)一律由 +//! `DownloadTuning` 显式盖上去,那一路的出厂值是 `none` —— 与设置页画的那颗 chip 一致, +//! 「界面说 none、实际偷偷走系统代理」正是本任务要治的病。 +//! +//! ## `proxy_url` 在非 manual 模式下:**忽略** +//! +//! 一句话决定:`proxy_url` 只在 `manual` 档被读;`none` / `system` 下它**既不校验、也不进入** +//! [`ProxySetting`],值原样留在 `launcher.json` 里(模式切回 `manual` 就立刻生效)。模式是唯一 +//! 开关,所以不存在「填了地址、模式没选对、半生效」这种中间态。 +//! +//! `manual` + **空地址** ⇒ [`ProxySetting::System`]:设置页那行文案承诺过「manual 模式生效;留空则 +//! 用系统代理」,这里按文案兑现。不选 `Direct` —— 那会让「留空」变成第三种没人写过的语义。 +//! +//! # 口令:**所有**用户可见 / 会落盘的字符串都先脱敏(r1 复审 p2-1) +//! +//! `proxy_url` 允许带 userinfo(`http://user:password@host:port`),而这个键在本仓就是凭据 +//! (schema 是 `AiPolicy::sensitive()`、`ull config get` 打 ``)。M295 第一版把 +//! `manual` 的原串整条写进 `summary()`(→ CLI stderr)与解析失败文案,等于把口令还原成明文。 +//! 现在**唯一**的出口是 [`redact_proxy_url`]:`user:password@` → `user:***@`(用户名保留,口令打掉; +//! 没有口令的地址**逐字不变**)。[`ProxySetting::describe`] / 错误文案 / `Debug` 都过它, +//! 宿主直接拼 `problem()` 的返回值即可,**不许**自己再拼一遍地址。 +//! +//! # 非法值不得穿透(两类都走同一条路:有界错误 + 回落直连 + 告知) +//! +//! 1. **用不了**:由 `validate` **自己**判四类——空地址 / scheme 不支持(`socks5://` 等)/ +//! 缺少主机名 / 端口不合法(空、`0`、越界)。每类各一句中文原因,**不读** reqwest 的 +//! `builder error`、也不匹配 `url` 的英文文案(那是「摊原文」,等于没解释); +//! 2. **reqwest 层残余的拒绝**(`validate` 放行但它不认,例如 userinfo 里有非法字符):中文结论在 +//! 前,reqwest 原文只作为括号里的证据。 +//! +//! 两种情况都是 `InvalidRequest` + 带(脱敏后)原串的可读文案,**不 panic**。 +//! +//! 装配点([`super::engine::DownloadEngine::new`])的处置是「回落直连 + 把原因留在引擎上」 +//! (`DownloadEngine::proxy_problem`):一个设置字符串不该让用户连文件都下不了,但也不许它悄悄变成 +//! 「用了某个不知道是什么的代理」。**回落必须被看见**:CLI 打一行 stderr,GUI 的设置页把 +//! [`ProxySetting::problem`] 的结论画在「HTTP 代理」那一行(两个宿主都调同一个判定,不各写一份)。 +//! +//! 一条实测(别把它当 bug 修掉):reqwest 的 `Proxy::all` 对**没有 scheme** 的串会自作主张补 +//! `http://` —— `127.0.0.1:7890` 是合法写法。所以「宽口径」只补到「少写 scheme」为止,判据自己算。 +//! +//! # 不落盘 +//! +//! 与 `preference` 同一条纪律:翻译只发生在装配那一刻,`launcher.json` 里存的永远是用户写的原文。 +//! +//! # 没验的部分 +//! +//! 真机上的「系统代理」在 Windows/macOS 上的注册表/偏好设置路径**没有**被读(本仓 reqwest 没开 +//! `client-proxy-system` feature ⇒ `system` 只认环境变量),PAC 脚本与需要认证(`Proxy-Authorization`) +//! 的代理同样没测。 + +use std::fmt; + +use reqwest::ClientBuilder; + +use super::error::{DownloadError, DownloadErrorKind}; + +/// `download.proxy_mode` + `download.proxy_url` 的翻译结论(**全仓唯一**一份)。 +/// +/// `Debug` 手写(不 derive):`Manual` 里的地址会过 [`redact_proxy_url`] —— 引擎 / 配置都会被 +/// `{:?}` 打进日志与诊断,那条路径不该是口令的第二出口。 +#[derive(Clone, PartialEq, Eq, Default)] +pub enum ProxySetting { + /// `none`(schema 出厂值):**真直连**,连环境变量里的代理也不读。 + #[default] + Direct, + /// `system`:跟随进程环境变量(reqwest 的默认行为,本档不调任何代理 API)。 + System, + /// `manual`:整条流量走这个地址。 + Manual { + /// `download.proxy_url` 的原串(已去前后空白;**未**校验,校验在 [`Self::apply`])。 + /// **原串留着**:真发请求要用它;一切对外字符串都先过 [`redact_proxy_url`]。 + 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 { + /// 设置原文 → 代理三态(**唯一**翻译入口:两个键一起读,规则见模块头)。 + /// + /// `mode` / `url` 的 `None` 是「键不存在 / 类型不对」(宿主侧 `effective_str` 的两种失败), + /// 与空串一样按「没填」处理:`mode` 没填 ⇒ `none`,`url` 没填 + `manual` ⇒ `system`。 + #[must_use] + pub fn from_raw(mode: Option<&str>, url: Option<&str>) -> Self { + let url = url.map(str::trim).unwrap_or_default(); + match mode + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "system" => Self::System, + "manual" if url.is_empty() => Self::System, + "manual" => Self::Manual { + url: url.to_string(), + }, + // `none` / 空串 / 未知值(手改文件写出来的 `mars`)都落这一档:与 schema 出厂值一致。 + _ => Self::Direct, + } + } + + /// 稳定字符串 id(与 schema 的取值逐字相同;日志/诊断用)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Direct => "none", + Self::System => "system", + Self::Manual { .. } => "manual", + } + } + + /// 一行「引擎实际拿到了什么」(`DownloadTuning::summary` 用:三档语义不同,只印 `as_str` + /// 会把「跟随环境变量」与「某个具体地址」印成同一句话)。 + /// + /// 地址过 [`redact_proxy_url`]:这行会进 CLI 的 stderr(`download::announce`)、进而进 CI 日志。 + #[must_use] + pub fn describe(&self) -> String { + match self { + Self::Direct => "none(直连)".to_string(), + Self::System => "system(跟随环境变量)".to_string(), + 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 { + match self { + Self::Manual { url } => manual_proxy(url).err().map(|err| err.message), + Self::Direct | Self::System => None, + } + } + + /// 把这一档盖到 client builder 上(**唯一**应用点:`http::build_client`)。 + /// + /// # Errors + /// + /// 只在 `manual` 档、且地址用不了时:`kind = InvalidRequest`,`message` 带上(脱敏后的)原串与 + /// **能读**的原因。**不 panic、不静默改成别的档**——回落由装配点决定,见模块头。 + pub fn apply(&self, builder: ClientBuilder) -> Result { + match self { + Self::Direct => Ok(builder.no_proxy()), + // 故意的空操作:reqwest 的默认行为就是「有显式代理用显式的,没有就读环境变量」, + // 这一档要的正是后者。写成 `.proxy(Proxy::system())` 会更像「显式」,但 reqwest 0.12 + // 没有这个公开构造函数(`system` 只在 `auto_sys_proxy` 为真时内部生效)。 + Self::System => Ok(builder), + Self::Manual { url } => Ok(builder.proxy(manual_proxy(url)?)), + } + } +} + +/// **口令脱敏的唯一实现**:`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` 的**同一条**路)。 +/// +/// 顺序:**先自己判**([`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 { + let shown = redact_proxy_url(url); + validate(url).map_err(|why| { + DownloadError::new( + DownloadErrorKind::InvalidRequest, + format!("代理地址用不了({shown}):{why}"), + ) + })?; + // 残余失败(validate 放行、但 reqwest 不认,例如 userinfo 里有非法字符):中文结论在前, + // reqwest 的原文只作为括号里的**证据**,不当结论。 + reqwest::Proxy::all(url).map_err(|err| { + DownloadError::new( + DownloadErrorKind::InvalidRequest, + 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::() { + 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 的名字),真正的 +/// 原因(`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)] +#[path = "proxy/tests.rs"] +mod tests; diff --git a/crates/core/src/download/proxy/tests.rs b/crates/core/src/download/proxy/tests.rs new file mode 100644 index 0000000..d8843ea --- /dev/null +++ b/crates/core/src/download/proxy/tests.rs @@ -0,0 +1,259 @@ +//! [`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 + r2 复审必修)。 +/// +/// 判据落在**最终文案**上:`problem()` 里出现该类的中文原因,且**没有**把 reqwest/url 的原文 +/// 当结论(`builder error`、`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 { + 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 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()); + 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} 是合法写法,不该被拒" + ); + } +} + +/// **两个默认值故意不同**(模块头那段的守卫):没读设置的子系统保持 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() + ); +} diff --git a/crates/core/tests/download/engine_mock/proxy.rs b/crates/core/tests/download/engine_mock/proxy.rs new file mode 100644 index 0000000..a0ec9b0 --- /dev/null +++ b/crates/core/tests/download/engine_mock/proxy.rs @@ -0,0 +1,315 @@ +//! **代理真的接上了吗**(M295):本地假代理 + 引擎的**生产装配路径**。 +//! +//! 判据是「请求真的经由代理」而不是「配置对象里存了个地址」——后者正是这条债原本的形态 +//! (`settings` 里存着 `manual` + 地址,`build_client` 从不 `.proxy()`,全绿)。 +//! +//! 三档各一条: +//! +//! | 用例 | 环境 | 期望 | +//! |---|---|---| +//! | [`manual_mode_really_sends_the_request_through_the_proxy`] | 无 | 目标域名**不可能解析**,仍拿到正文 ⇒ 只可能来自假代理;且代理记到的请求行是**绝对 URI** | +//! | [`direct_ignores_the_env_proxy_and_system_follows_it`] | `HTTP_PROXY` 指向假代理 | `system` 拿到正文(经由代理);**同一个环境**下 `direct` 拿不到(`.no_proxy()`),且代理一条新请求都没收到 | +//! | [`an_unparseable_proxy_url_falls_back_to_direct_and_says_why`] | 无 | 不 panic;`proxy_problem()` 有可读原因;配置字段仍是用户写的 `manual`(不撒谎) | +//! +//! 变异(只改实现侧,跑完即还原): +//! * 删掉 `ProxySetting::apply` 里的 `.proxy(proxy)` ⇒ 第 1 条红(其余两条绿); +//! * 删掉 `ProxySetting::apply` 里的 `.no_proxy()` ⇒ 第 2 条红(`direct` 也会经代理成功); +//! * 把 `ProxySetting::System` 改成 `.no_proxy()` ⇒ 第 2 条红(`system` 拿不到正文)。 +//! +//! 抓不住:真机上的「系统代理」(Windows 注册表 / macOS 偏好设置,本仓 reqwest 没开 +//! `client-proxy-system`,`system` 只认环境变量)、PAC 脚本、需要认证的代理、HTTPS 目标的 +//! `CONNECT` 隧道(本夹具只讲明文 HTTP)。 + +use std::sync::{Arc, Mutex}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +use unlucky_core::download::proxy::ProxySetting; + +use super::*; + +/// 本文件的用例**串行**跑:`direct_ignores_the_env_proxy_and_system_follows_it` 会改进程环境变量 +/// (全局状态),并发跑时邻座的客户端会跟着读到它——那会让失败原因变成「别人的环境」,而不是 +/// 自己被变异的那一行。三个用例都先拿这把锁(同 `core::update` 的环境变量用例手法)。 +static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// 用**异步**互斥锁而不是 `std::sync::Mutex`:三个用例都是 `#[tokio::test]`,持着标准锁跨越 +/// `await` 会被 `clippy::await_holding_lock` 拦下(本仓 clippy 是 `-D warnings`)。 +async fn serial() -> tokio::sync::MutexGuard<'static, ()> { + ENV_LOCK.lock().await +} + +/// 假代理:只回一份固定正文,并把每条请求的**第一行**记下来。 +/// +/// 为什么记请求行就够:明文 HTTP 走代理时请求行是**绝对 URI**(`GET http://host/path HTTP/1.1`), +/// 直连永远只有 `/path`。所以「记到绝对 URI」等价于「这条请求是代理发出的」。 +struct FakeProxy { + addr: std::net::SocketAddr, + lines: Arc>>, + handle: tokio::task::JoinHandle<()>, +} + +impl FakeProxy { + async fn start(body: &'static [u8]) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("绑定假代理端口"); + let addr = listener.local_addr().expect("取假代理地址"); + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&lines); + let handle = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let seen = Arc::clone(&seen); + tokio::spawn(async move { + let mut head = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + match socket.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(read) => head.extend_from_slice(&chunk[..read]), + } + if head.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let text = String::from_utf8_lossy(&head); + let first = text.lines().next().unwrap_or_default().to_string(); + seen.lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(first); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let _ = socket.write_all(header.as_bytes()).await; + let _ = socket.write_all(body).await; + }); + } + }); + Self { + addr, + lines, + handle, + } + } + + fn url(&self) -> String { + format!("http://{}", self.addr) + } + + fn request_lines(&self) -> Vec { + self.lines + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } +} + +impl Drop for FakeProxy { + fn drop(&mut self) { + self.handle.abort(); + } +} + +/// 一个引擎 + 一份缓存根(与 `engine_mock_test.rs` 的 `harness` 同一形状,只多了一个代理档)。 +fn engine_with_proxy(proxy: ProxySetting, tag: &str) -> Harness { + harness( + EngineConfig { + proxy, + ..fast_config(1) + }, + MirrorTable::builtin(), + direct_region(), + tag, + ) +} + +/// 目标 URL 用 RFC 2606 的保留域名 `.invalid`:**不可能解析**,所以「拿到正文」只可能是因为 +/// 请求经由假代理(DNS 不会骗人,除非真走了代理)。 +const TARGET: &str = "http://proxywire.invalid/meta.json"; +const BODY: &[u8] = b"proxied payload"; + +fn request_for(harness: &Harness) -> DownloadRequest { + DownloadRequest::new(ResourceKind::Other, TARGET, harness.dir.join("meta.json")) +} + +/// **manual 档真的走代理**(任务书的头号判据)。 +/// +/// 变异:删掉 `ProxySetting::apply` 里那一行 `.proxy(proxy)` ⇒ 目标域名解析失败,`expect` 当场红。 +#[tokio::test] +async fn manual_mode_really_sends_the_request_through_the_proxy() { + let _serial = serial().await; + let proxy = FakeProxy::start(BODY).await; + let harness = engine_with_proxy( + ProxySetting::Manual { url: proxy.url() }, + "engine-proxy-manual", + ); + + let bytes = harness + .engine + .download_bytes(request_for(&harness)) + .await + .expect("manual 档必须能经由代理拿到正文(否则 .proxy() 那行没生效)"); + assert_eq!(bytes, BODY, "拿到的必须是假代理回的那份字节"); + + let lines = proxy.request_lines(); + assert_eq!(lines.len(), 1, "应该恰好一次经由代理的请求:{lines:?}"); + assert!( + lines[0].starts_with("GET http://proxywire.invalid/meta.json"), + "走代理的请求行必须是**绝对 URI**(这是「经由代理」的直接证据):{lines:?}" + ); + assert!( + harness.engine.proxy_problem().is_none(), + "地址合法时不该有代理问题:{:?}", + harness.engine.proxy_problem() + ); + std::fs::remove_dir_all(&harness.dir).ok(); +} + +/// **direct 不读环境变量、system 跟随环境变量**(同一份环境、同一个目标,只有设置不同)。 +/// +/// 这里动的是**进程环境变量**(全局状态),所以用一把进程内锁串起来、退出时还原原值;同时把 +/// `NO_PROXY` 钉成回环,让同进程里其它打回环 HTTP 的用例(`engine_mock` 的兄弟文件)绕过假代理, +/// 不被这一条污染。 +/// +/// 变异:`Direct` 少了 `.no_proxy()` ⇒ 第一段红(`.invalid` 会经由假代理「成功」,代理多记一行); +/// `System` 被写成 `.no_proxy()` ⇒ 第二段红(拿不到正文)。 +#[tokio::test] +async fn direct_ignores_the_env_proxy_and_system_follows_it() { + let _serial = serial().await; + + /// 记住两个变量的原值,`Drop` 时还原(用例 panic 也还原)。 + struct Restore(Option, Option); + impl Drop for Restore { + fn drop(&mut self) { + for (key, value) in [("HTTP_PROXY", self.0.clone()), ("NO_PROXY", self.1.clone())] { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + + let proxy = FakeProxy::start(BODY).await; + let _restore = Restore(std::env::var_os("HTTP_PROXY"), std::env::var_os("NO_PROXY")); + std::env::set_var("HTTP_PROXY", proxy.url()); + std::env::set_var("NO_PROXY", "127.0.0.1,localhost"); + + // ① system:跟随环境变量里的 HTTP_PROXY ⇒ 经由假代理拿到正文。 + let system = engine_with_proxy(ProxySetting::System, "engine-proxy-system"); + let bytes = system + .engine + .download_bytes(request_for(&system)) + .await + .expect("system 档必须跟随环境变量里的代理"); + assert_eq!(bytes, BODY); + let lines = proxy.request_lines(); + assert_eq!(lines.len(), 1, "system 档应该经由代理一次:{lines:?}"); + std::fs::remove_dir_all(&system.dir).ok(); + + // ② direct:**同一个环境变量、同一个目标** ⇒ 一个字节都不许经代理。 + let direct = engine_with_proxy(ProxySetting::Direct, "engine-proxy-direct"); + let error = direct + .engine + .download_bytes(request_for(&direct)) + .await + .expect_err("direct 档不许经由环境变量里的代理拿到 .invalid 的正文"); + assert_eq!( + proxy.request_lines().len(), + lines.len(), + "direct 档一条请求都不许发给代理(`.no_proxy()` 没生效):{:?}", + proxy.request_lines() + ); + assert_eq!( + error.kind, + DownloadErrorKind::AllSourcesFailed, + "回落成「这个域名连不上」的有界失败,不是别的:{}", + error.message + ); + std::fs::remove_dir_all(&direct.dir).ok(); +} + +/// **非法地址不得穿透**(任务书第 4 条):不 panic、有可读原因、配置不撒谎、回落是直连。 +/// +/// 变异:把 `build_client` 里 `cfg.proxy.apply(builder)?` 的 `?` 改成 `expect` ⇒ 本用例 panic(红); +/// 把引擎的回落改成 `reqwest::Client::new()`(跟随环境变量)⇒ `proxy_problem()` 仍在但回落不确定 +/// (本机没有代理时这条断言照样绿——所以「回落是直连」由 `direct` 用例的 `.no_proxy()` 那半边兜, +/// 这里只说清「配置没被偷偷改掉」这一条)。 +#[tokio::test] +async fn an_unparseable_proxy_url_falls_back_to_direct_and_says_why() { + let _serial = serial().await; + let broken = ProxySetting::Manual { + url: "http://127.0.0.1:99999".to_string(), + }; + let harness = engine_with_proxy(broken.clone(), "engine-proxy-broken"); + + let problem = harness + .engine + .proxy_problem() + .expect("地址解析不了必须留下原因(宿主靠它告诉用户)"); + assert!(problem.contains("http://127.0.0.1:99999"), "{problem}"); + assert!(problem.contains("代理地址用不了"), "{problem}"); + assert!(problem.contains("端口 99999"), "原因要能读:{problem}"); + assert!(!problem.contains("builder error"), "{problem}"); + assert_eq!( + harness.engine.config().proxy, + broken, + "配置字段仍是用户写的 manual(回落是运行时行为,不是偷偷改配置)" + ); + + // 回落直连 ⇒ 请求打到一个不可能解析的域名上是有界失败,既不是 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(); +} + +/// **`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(); +} diff --git a/crates/core/tests/download/engine_mock_test.rs b/crates/core/tests/download/engine_mock_test.rs index 24e99d1..51daace 100644 --- a/crates/core/tests/download/engine_mock_test.rs +++ b/crates/core/tests/download/engine_mock_test.rs @@ -372,6 +372,10 @@ mod timeout; #[path = "engine_mock/bytes_timeout.rs"] mod bytes_timeout; +/// 代理真的接上(M295):本地假代理 + 生产装配路径,判据是「请求真的经由它」。 +#[path = "engine_mock/proxy.rs"] +mod proxy; + /// 上面两条共用的夹具(引擎构造 / 外层保险 / 静默脚本)。 #[path = "engine_mock/harness.rs"] mod harness; diff --git a/crates/presenter/src/host/settings/download.rs b/crates/presenter/src/host/settings/download.rs index ad7800b..17d5cc9 100644 --- a/crates/presenter/src/host/settings/download.rs +++ b/crates/presenter/src/host/settings/download.rs @@ -18,6 +18,8 @@ //! download.throughput_floor_kib ─┤ (引擎重建时生效) //! download.stall_timeout_secs ───┤ //! download.response_timeout_secs ┤ +//! download.proxy_mode ───────────┤ M295:代理两键进 RawTuning, +//! download.proxy_url ────────────┤ 三档翻译在 core 的 ProxySetting //! download.region ───────────────┤ M294:两个枚举键,走 //! download.mirror_preference ────┘ `with_source_selection`(回落 = auto) //! ``` @@ -78,6 +80,13 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView { let region_value = text(env, "download.region"); let mirror_value = text(env, "download.mirror_preference"); let proxy_value = text(env, "download.proxy_mode"); + // M295 r1(复审 p2-2):非法 / 本构建用不上的代理地址 ⇒ 引擎回落**直连**。这条提示是 GUI 侧 + // 唯一的可见面(CLI 打在 stderr)——没有它,用户以为流量在代理里、实际裸奔。 + // 判定与引擎同源(`ProxySetting::problem` = `apply` 的同一个 `manual_proxy`),文案已脱敏。 + let proxy_note = tuning + .proxy + .problem() + .map(|problem| format!("已回落到直连:{problem}")); SettingGroupView::new("download", "下载与镜像(D7)") .with(SettingRowView::choice( unlucky_core::download::config::KEY_CONCURRENCY, @@ -134,7 +143,7 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView { .with(SettingRowView::choice( "download.proxy_mode", "代理模式", - "none / system(跟随系统)/ manual(下面填地址)", + "none = 不走代理(连环境变量里的 HTTP_PROXY 也不读)/ system = 跟随系统环境变量代理 / manual = 用下面填的地址", &proxies, selected( &proxies, @@ -145,10 +154,17 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView { }, ), )) + // M295:这一行只在 manual 档生效;别的档下地址被**忽略**(值仍留在文件里),地址解析不了 + // 时引擎回落直连并说明原因——三件事都写进文案,否则「填了没生效」又得靠用户猜。 + // r1 复审 p2-2:`proxy_note` 是**运行时**结论(这一档真的用不了),独占一行接在静态说明后面 + // ——「回落了却不说」等于让用户在以为走代理的情况下裸奔。 .with(SettingRowView::text( "download.proxy_url", "HTTP 代理", - "manual 模式生效;留空则用系统代理", + &with_note( + "只在 manual 模式生效(其它模式忽略这段地址);manual 留空则用系统代理;地址写法不对时按直连处理,原值保留".to_string(), + proxy_note.as_deref(), + ), &text(env, "download.proxy_url"), )) // M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把 @@ -256,11 +272,16 @@ fn gh_proxy_state(state: &GhProxyOverride) -> String { /// M294:另加两个「源选择」键(`download.region` / `download.mirror_preference`),它们是**枚举** /// 而不是数字,走 [`DownloadTuning::with_source_selection`](同一个翻译层,回落 = `auto`)。 /// 少了这一步,那两行 chip 就只是画着好看——本轮之前正是如此。 +/// +/// M295:代理两键(`download.proxy_mode` / `download.proxy_url`)进 [`RawTuning`](它们是字符串 +/// 原文 + 一个开关,与 `gh_proxy` 同形),三档翻译与地址校验都在 core 的 +/// `DownloadTuning::from_raw` / `ProxySetting`,**本函数不解释任何一个字**。 #[must_use] pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning { use unlucky_core::download::config::{ RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, KEY_MIRROR_PREFERENCE, - KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR, + KEY_PROXY_MODE, KEY_PROXY_URL, KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, + KEY_THROUGHPUT_FLOOR, }; let store = env.store(); let doc = store.doc(); @@ -275,6 +296,8 @@ pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning { throughput_floor_kib: doc.effective_i64(KEY_THROUGHPUT_FLOOR), stall_timeout_secs: doc.effective_i64(KEY_CHUNK_STALL), response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT), + proxy_mode: doc.effective_str(KEY_PROXY_MODE), + proxy_url: doc.effective_str(KEY_PROXY_URL), }) .with_source_selection( doc.effective_str(KEY_REGION), diff --git a/crates/presenter/src/host/settings/download_tests.rs b/crates/presenter/src/host/settings/download_tests.rs index 9b40427..0d275b2 100644 --- a/crates/presenter/src/host/settings/download_tests.rs +++ b/crates/presenter/src/host/settings/download_tests.rs @@ -383,16 +383,5 @@ fn the_three_timeout_keys_travel_from_the_file_to_the_engine_config() { std::fs::remove_dir_all(&dir).ok(); } -/// **M294**:两个「源选择」键真的进了 `DownloadTuning`(删掉 `with_source_selection` ⇒ 两断言红)。 -#[test] -fn the_two_source_selection_keys_reach_the_tuning() { - use unlucky_core::download::{MirrorPreference, RegionPreference}; - let (dir, env) = env_with( - "source-selection", - serde_json::json!({ "download": { "region": "global", "mirror_preference": "official" } }), - ); - let tuning = tuning_of(&env); - assert_eq!(tuning.region, RegionPreference::Global); - assert_eq!(tuning.mirror_preference, MirrorPreference::Official); - std::fs::remove_dir_all(&dir).ok(); -} +#[path = "download_tests/selection_and_proxy.rs"] +mod selection_and_proxy; diff --git a/crates/presenter/src/host/settings/download_tests/selection_and_proxy.rs b/crates/presenter/src/host/settings/download_tests/selection_and_proxy.rs new file mode 100644 index 0000000..b3ccd50 --- /dev/null +++ b/crates/presenter/src/host/settings/download_tests/selection_and_proxy.rs @@ -0,0 +1,200 @@ +//! **最近两轮接进来的两个键族**的守门:源选择(M294)与代理(M295)。 +//! +//! 从 `download_tests.rs` 搬出只为守 400 行硬线(与 `support/server.rs` 拆出同一理由):那条文件 +//! 里这一族是最后两段,搬过来正文一字未改,只是多了一句「搬家原因」。 +//! +//! ## 代理两键(M295):`download.proxy_mode` / `download.proxy_url` +//! +//! 从文件走到**引擎配置**,走的是 GUI 的生产装配点 `host::engine::engine`(内部 `apply_tuning` → +//! `shared::set_tuning` → 按数据根重建)。 +//! +//! 抓得住:① `tuning_of` 少读一个键(`proxy_mode` 或 `proxy_url`)⇒ 第一、三条红;② 非 manual 档 +//! 也把地址塞进 `Manual` ⇒ 第一条红;③ `manual` + 空地址落成 `Direct`(设置页承诺的是「用系统代理」) +//! ⇒ 第四条红;④ 非法地址让引擎构造 panic ⇒ 最后一段红;⑤ 两行文案与三档语义脱钩 ⇒ 文案断言红。 +//! +//! 抓不住:请求是不是**真的**经由代理(那是 core 的 `tests/download/engine_mock/proxy.rs`:本地假 +//! 代理 + `.invalid` 目标 + 绝对 URI 请求行);也抓不住发布版 GUI 的「下载 JDK」页——那条路径仍用 +//! `EngineConfig::default()`(见 `docs/download-settings.md` §3.1.1 的 ❌ 一行)。 + +use super::*; +use unlucky_core::download::proxy::ProxySetting; + +/// 写文件 → 走生产装配点 → 返回「引擎拿到的代理档 + 引擎自报的问题」。 +fn applied(tag: &str, document: serde_json::Value) -> (ProxySetting, Option) { + let (dir, env) = env_with(tag, document); + std::fs::create_dir_all(env.layout.cache()).expect("cache 目录"); + let engine = crate::host::engine::engine(&env); + let seen = ( + engine.config().proxy.clone(), + engine.proxy_problem().map(str::to_string), + ); + std::fs::remove_dir_all(&dir).ok(); + seen +} + +#[test] +fn the_proxy_keys_travel_from_the_file_to_the_engine_config() { + use unlucky_core::download::config::{EngineConfig, KEY_PROXY_MODE, KEY_PROXY_URL}; + + // ① none(也是 schema 出厂值):直连;**非 manual 档的地址被忽略**。 + assert_eq!( + applied( + "proxy-none", + serde_json::json!({ "download": { "proxy_mode": "none", "proxy_url": "http://127.0.0.1:7890" } }) + ), + (ProxySetting::Direct, None), + "none 档必须是真直连,且地址不参与" + ); + + // ② system:跟随环境变量(同样是「不调代理 API」那一档,地址仍被忽略)。 + assert_eq!( + applied( + "proxy-system", + serde_json::json!({ "download": { "proxy_mode": "system", "proxy_url": "http://127.0.0.1:7890" } }) + ), + (ProxySetting::System, None) + ); + + // ③ manual:地址进结构(去空白)。 + assert_eq!( + applied( + "proxy-manual", + serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": " http://127.0.0.1:7890 " } }) + ), + ( + ProxySetting::Manual { + url: "http://127.0.0.1:7890".to_string() + }, + None + ) + ); + + // ④ manual + 空地址 ⇒ 设置页那行文案承诺的「用系统代理」。 + assert_eq!( + applied( + "proxy-manual-empty", + serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": "" } }) + ), + (ProxySetting::System, None) + ); + + // ⑤ 非法地址:不 panic、有可读原因、配置不撒谎(仍是用户写的 manual)。 + let (proxy, problem) = applied( + "proxy-broken", + serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": "http://127.0.0.1:99999" } }), + ); + assert_eq!( + proxy, + ProxySetting::Manual { + url: "http://127.0.0.1:99999".to_string() + } + ); + let problem = problem.expect("解析不了必须留下原因(否则用户只会觉得「填了没用」)"); + assert!(problem.contains("http://127.0.0.1:99999"), "{problem}"); + + // 两行文案必须与新语义一致(否则界面又回到「填了不知道算不算数」)。 + let (dir, env) = env_with("proxy-rows", serde_json::json!({})); + let group_view = group(&env); + let mode_row = row(&group_view, KEY_PROXY_MODE); + assert!( + mode_row + .description + .contains("system = 跟随系统环境变量代理") + && mode_row.description.contains("manual"), + "代理模式那行必须把三档说清:{}", + mode_row.description + ); + let url_row = row(&group_view, KEY_PROXY_URL); + assert!( + url_row.description.contains("只在 manual 模式生效"), + "地址那行必须说清它在别的档被忽略:{}", + url_row.description + ); + assert_eq!( + EngineConfig::default().proxy, + ProxySetting::System, + "「没读设置」的档仍是跟随环境变量(auth / JDK 页走它),与本文件的三档翻译是两件事" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// **M294**:两个「源选择」键真的进了 `DownloadTuning`(删掉 `with_source_selection` ⇒ 两断言红)。 +#[test] +fn the_two_source_selection_keys_reach_the_tuning() { + use unlucky_core::download::{MirrorPreference, RegionPreference}; + let (dir, env) = env_with( + "source-selection", + serde_json::json!({ "download": { "region": "global", "mirror_preference": "official" } }), + ); + let tuning = tuning_of(&env); + assert_eq!(tuning.region, RegionPreference::Global); + assert_eq!(tuning.mirror_preference, MirrorPreference::Official); + std::fs::remove_dir_all(&dir).ok(); +} + +/// **回落必须被用户看见**(r1 复审 p2-2 的 GUI 侧判据)。 +/// +/// 断的是**最终的文案**(设置页「HTTP 代理」那一行):出现「已回落到直连」+ 原因,独占一行,且 +/// 原因里没有口令;合规地址那一行**没有**这句(否则天天挂一条假警告)。 +/// +/// 同源证明:这一行的原因与引擎的 `proxy_problem()` 是同一串(宿主调 `ProxySetting::problem`, +/// 引擎走 `apply`,两者共用 `manual_proxy` 那一个判定)。 +/// +/// 变异:删掉 `group()` 里的 `proxy_note` ⇒ 第一段红(文案里没有「已回落到直连」);让 +/// `describe`/错误文案回到未脱敏的原串 ⇒ 第三段红(`contains(SECRET)`)。 +#[test] +fn a_broken_proxy_url_is_shown_as_falling_back_to_direct() { + use unlucky_core::download::config::KEY_PROXY_URL; + + const SECRET: &str = "PWMUSTNOTLEAK"; + let (dir, env) = env_with( + "proxy-hint-broken", + serde_json::json!({ "download": { + "proxy_mode": "manual", + "proxy_url": format!("http://user:{SECRET}@127.0.0.1:99999"), + }}), + ); + let group_view = group(&env); + let url_row = row(&group_view, KEY_PROXY_URL); + assert!( + url_row.description.contains("\n已回落到直连:"), + "用不了的代理地址必须在设置页说出来(独占一行):{:?}", + url_row.description + ); + assert!( + url_row.description.contains("99999"), + "提示里要带上用户写的那个地址(脱敏后):{}", + url_row.description + ); + assert!( + !url_row.description.contains(SECRET), + "提示里泄漏了代理口令:{}", + url_row.description + ); + + // 与引擎同源:宿主这一行说的就是引擎实际记录的那条原因。 + std::fs::create_dir_all(env.layout.cache()).expect("cache 目录"); + let problem = crate::host::engine::engine(&env) + .proxy_problem() + .map(str::to_string) + .expect("引擎侧也该记录这条原因"); + assert!( + url_row.description.contains(&problem), + "设置页与引擎说的不是同一件事:{problem}" + ); + std::fs::remove_dir_all(&dir).ok(); + + // 合规地址 ⇒ 不许有这条提示(假警告会让真的那条失效)。 + let (dir, env) = env_with( + "proxy-hint-ok", + serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": "http://127.0.0.1:7890" } }), + ); + let ok_group = group(&env); + let ok_row = row(&ok_group, KEY_PROXY_URL); + assert!( + !ok_row.description.contains("已回落到直连"), + "合规地址不该有回落提示:{}", + ok_row.description + ); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/docs/download-settings.md b/docs/download-settings.md index 1019123..9b06b99 100644 --- a/docs/download-settings.md +++ b/docs/download-settings.md @@ -2,8 +2,10 @@ 一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 真的进引擎**; `download.region` / `download.mirror_preference` 自 **M294** 起也真的进引擎(GUI 主路径、发布版 JDK -下载页、CLI 四条命令都认,见 §5);只剩 `proxy_mode` / `proxy_url` 仍是**引擎侧零消费者**—— -这一页把它们逐条标出来,别再让「设置项说假话」。 +下载页、CLI 四条命令都认,见 §5);`download.proxy_mode` / `download.proxy_url` 自 **M295** 起接进 +HTTP 客户端的代理三态(GUI 主路径、CLI 四条命令都认,**发布版 GUI 的「下载 JDK」页仍不认**,见 +§3.1.1)。还剩的缺口只有 §3.1.1 表里那几个 ❌——这一页把八个下载类键逐条标出来,别再让 +「设置项说假话」。 出处:M190 的 204 条 finding 复核(`DevDocs/PlanDoc/research/191-finding-triage.md` §3 A-3)挖出的 「用户能改、改了没用」缺口:schema 说并发 8,引擎恒跑 64,两处各写一个数。 @@ -19,8 +21,8 @@ | `download.gh_proxy` | 空串 / ≤256 字符(M197 登记) | 共享引擎装配点(`shared::build` 的 gh-proxy 候选链)+ CLI 的 `download::tuning` | 地区档案里的内置值(镜像优先时 `gh-proxy.com`) | ✅ 下沉到共享装配点,GUI 与四条 CLI 命令一致 | | `download.region` | auto / auto·cn·global | **M294 起有**:`DownloadTuning::with_source_selection` → `RegionProfile::overridden`,装配点是 `shared::build` 与 `app-iced` 的 JDK 下载页 | 永远按「时区+语言」自动判定 | ✅ 接进引擎(M294):显式 `cn`/`global` 压过探测 | | `download.mirror_preference` | auto / auto·official·mirror | 同上(同一个入口) | 永远按地区模式(镜像优先/直连) | ✅ 接进引擎(M294):优先级 `mirror_preference` > `region` > 探测 | -| `download.proxy_mode` | none / none·system·manual | **无**(`http::build_client` 从不调 `.proxy()`) | 只走环境变量代理(reqwest 默认),`system`/`manual` 都不生效 | ❌ 未接(见 §5) | -| `download.proxy_url` | "" | **无** | 空(从不使用) | ❌ 未接(见 §5) | +| `download.proxy_mode` | none / none·system·manual | **M295 起有**:`DownloadTuning::from_raw` → `ProxySetting`,唯一消费点是 `http::build_client` | 只走环境变量代理(reqwest 默认):`none` / `manual` 都不生效,`system` 只是「碰巧」 | ✅ 接进引擎(M295):`none` = `.no_proxy()`、`system` = 跟随环境变量、`manual` = `Proxy::all(url)` | +| `download.proxy_url` | "" | 同上(**只在 manual 档被读**) | 空(从不使用) | ✅ 接进引擎(M295);非 manual 档**忽略**(值原样留在文件里) | | `download.action.detect_region` | (动作键,不是设置) | presenter 的 `detect_region` | 清 `region.json` + 失效共享引擎 | ✅ 本来就真 | 复现方法(改造前):`grep -rn "download.concurrency" crates/` 只命中 `setting/keys.rs` 的定义与 @@ -50,7 +52,9 @@ crates/core/src/download/config.rs config/launcher.json download.concurrency ─┐ download.retry_limit ─┼→ presenter::host::settings::download::tuning_of - download.gh_proxy ────┤ │ DownloadTuning::from_settings(默认值/夹取/回落都在 core) + download.gh_proxy ────┤ │ DownloadTuning::from_raw(默认值/夹取/回落都在 core) + download.proxy_mode ──┤ │ ← M295:两键进 RawTuning,三档翻译在 ProxySetting + download.proxy_url ───┤ │ download.region ──────┤ │ download.mirror_preference ─┐ │ ← M294:with_source_selection(GUI/CLI/JDK 页共用的唯一入口) ↓ @@ -58,10 +62,11 @@ config/launcher.json ↓ 变了 → 淘汰该根的登记项 shared::engine_for() → shared::build ↓ - EngineConfig { max_concurrency, max_attempts_per_source } + EngineConfig { max_concurrency, max_attempts_per_source, proxy } + RegionProfile { mode(源选择叠出来的), gh_proxy } ↓ Semaphore(容量) / attempts_for() / MirrorTable::expand() 的候选顺序 + gh-proxy 候选 + / http::build_client 的 .no_proxy() | 环境变量 | .proxy(Proxy::all(url)) ``` **同步点是显式的**,两处,共用同一个函数 `apply_tuning`: @@ -90,17 +95,21 @@ config/launcher.json `shared::engine_with_tuning(, tuning)`。所以 `presenter` 的 `shared::build` 不再是 **全仓唯一**的装配点,但装配规则仍只有 core 那一份。 -### 3.1.1 「源选择」两键(`region` / `mirror_preference`)的覆盖面(M294) +### 3.1.1 「源选择」两键(M294)与代理两键(M295)的覆盖面 -| 路径 | 吃吗 | 装配点 | -|---|---|---| -| GUI 主路径(内容库、版本清单、资源、字体、自更新…) | ✅ | `shared::engine_for` → `shared::build`(`tune.apply_source_selection`) | -| 发布版 GUI 的「下载 JDK」页 | ✅(M294 r1 补) | `app-iced` 的 `pages/settings/java/downloads/engine.rs::engine_with`(独立装配点:它要 `with_progress` 拿走引擎) | -| CLI `ull install` / `java` / `pack` / `update` | ✅(M294 r1 补) | 四条共用 `cli/src/download.rs::tuning`(`.with_source_selection`) | -| `proxy_mode` / `proxy_url` | ❌ | 仍是零消费者,见 §5 | +| 键族 | 路径 | 吃吗 | 装配点 | +|---|---|---|---| +| `region` / `mirror_preference` | GUI 主路径(内容库、版本清单、资源、字体、自更新…) | ✅ | `shared::engine_for` → `shared::build`(`tune.apply_source_selection`) | +| | 发布版 GUI 的「下载 JDK」页 | ✅(M294 r1 补) | `app-iced` 的 `pages/settings/java/downloads/engine.rs::engine_with`(独立装配点:它要 `with_progress` 拿走引擎) | +| | CLI `ull install` / `java` / `pack` / `update` | ✅(M294 r1 补) | 四条共用 `cli/src/download.rs::tuning`(`.with_source_selection`) | +| `proxy_mode` / `proxy_url` | GUI 主路径 | ✅(M295) | `shared::build`:`tune.apply_to(EngineConfig::default())` 把 `proxy` 盖上去 → `http::build_client` | +| | 发布版 GUI 的「下载 JDK」页 | ❌(M295 未接) | 那一处直接用 `EngineConfig::default()`(= `System`,跟随环境变量代理),**不读**这两个键 | +| | CLI `ull install` / `java` / `pack` / `update` | ✅(M295) | 同一个 `cli/src/download.rs::tuning`(进 `RawTuning`,宿主零字符串匹配) | -三个宿主读的是**同一个**翻译层 `DownloadTuning::with_source_selection`(`auto` 回落、优先级、 -未知值怎么退都只写在 core 一处);宿主只负责「把 `launcher.json` 里那两个键读出来并显式传下去」。 +「源选择」两个键的翻译层是 `DownloadTuning::with_source_selection`(`auto` 回落、优先级、未知值 +怎么退都只写在 core 一处);代理两键的翻译层是 `ProxySetting::from_raw`(三档 + 那两句「非 manual +忽略地址」「manual 留空 = 系统代理」,同样只写在 core 一处)。两边都是:**宿主只负责把 +`launcher.json` 里的键读出来并显式传下去**。 回落仍是静默的:`SettingStore::load` 永不失败(坏值只进 `problems()`),类型不对 → `None`,越界 → 出厂 @@ -127,7 +136,7 @@ config/launcher.json 这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现 「界面显示 0、引擎按 3 跑」。 -## 5. 源选择两键已接(M294);只剩代理两键没接 +## 5. 源选择两键(M294)与代理两键(M295)都已接 ### 5.1 已接:`download.region` / `download.mirror_preference`(M294) @@ -152,18 +161,99 @@ config/launcher.json * gh-proxy 三态在源选择**之后**叠(`apply_source_selection` 的次序),所以 「`region=cn` + `gh_proxy=off`」⇒ 一个前缀都不生成(守卫:`an_explicit_gh_proxy_off_wins_over_a_mirror_first_region`)。 -### 5.2 未接:`download.proxy_mode` / `download.proxy_url` +### 5.2 已接:`download.proxy_mode` / `download.proxy_url`(M295) -**引擎侧零消费者**:`http::build_client` 只有 UA / 连接超时 / 重定向 / 默认头,**从不调 `.proxy()`**。 -于是 `manual`(指定地址)与 `none`(强制直连)都不生效;`system` 那一档「碰巧能用」只是因为 -reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是这段代码做的。要接需要: +**M295 之前引擎侧零消费者**:`http::build_client` 只有 UA / 连接超时 / 重定向 / 默认头,**从不调 +`.proxy()`**。于是 `manual`(指定地址)与 `none`(强制直连)都不生效;`system` 那一档「碰巧能用」 +只是因为 reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是那段代码做的。 -* `EngineConfig` 加代理三态 → `http::build_client` 里 `.proxy(reqwest::Proxy::all(url)?)` / `.no_proxy()`; -* 探测路径(`region::probe_signals`)与自更新要不要走同一个代理,是**另一个决定**,得先定。 +现在两个键翻译成 `EngineConfig.proxy`(`core::download::proxy::ProxySetting`),唯一消费点是 +`http::build_client`: -已开 finding:`download.proxy_mode` / `download.proxy_url` 零消费者(代理问的是「怎么连」, -与「连哪里」不是同一个决定面,混在一个 diff 里审不动)。`setting/keys/proxy.rs` 的模块头那句 -「`download.proxy_mode` → 引擎的 HTTP 客户端(`http::build_client`)」**目前是假的**,接线时一并改。 +| `proxy_mode` | `ProxySetting` | 落到 client 上 | 含义 | +|---|---|---|---| +| `none`(schema 出厂值,键不存在也走这档) | `Direct` | `.no_proxy()` | **真直连**:连进程环境里的 `HTTP_PROXY` 也不读 | +| `system` | `System` | **不调任何代理 API** | 跟随进程环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`)= M295 之前所有下载的实际行为 | +| `manual` | `Manual { url }` | `.proxy(reqwest::Proxy::all(url)?)` | 整条流量走 `proxy_url`(http 与 https 目标都经它;`Proxy::all` 顺手关掉「自动读系统代理」) | + +三条决定写在这里,省得下一个人再猜: + +* **`proxy_url` 在非 manual 模式下被忽略**:`none` / `system` 下它既不校验、也不进 `ProxySetting`, + 值原样留在 `launcher.json` 里(切回 `manual` 立刻生效)。模式是唯一开关,不存在「填了地址、模式 + 没选对、半生效」的中间态。 +* **`manual` + 空地址 = 用系统代理**:设置页那行文案一直这么承诺(「manual 留空则用系统代理」), + 核心按文案兑现,而不是落成第三种没人写过的语义。 +* **非法地址不穿透**:`proxy_url` 写成解析不了的串时,`ProxySetting::apply` 返回有界错误 + (`InvalidRequest` + 带原串的可读文案),**不 panic**;`DownloadEngine::new` 据此回落**直连**重建 + (不是 `reqwest::Client::new()`——那会跟随环境变量代理,回落就不确定了),原因留在 + `DownloadEngine::proxy_problem()` 上,CLI 的 `download::engine` 打一行 stderr。配置字段**不撒谎**: + `config().proxy` 仍是用户写的 `manual`。 + +**这是一次真实的行为变化**:`none` 从「碰巧跟随环境变量」变成「真直连」。所以 + +* 靠 `HTTP_PROXY` 上网的用户(公司代理 / CI)要在设置页选一次 `system`,否则 M295 之后下载会直连; +* 反过来,之前选过「直连」却仍在被环境变量代理的人,现在拿到的是他选的那一档。 +* `region::probe_signals`(地区探测)、`auth` 的登录与发布版 GUI 的「下载 JDK」页**都不读**这两个键, + 它们仍用 `EngineConfig::default()`(= `System`,跟随环境变量):这些路径的默认值故意与 + `download.proxy_mode` 的出厂值(`none`)不同,才没被这次接线静默改了网络路径。 + +`setting/keys/proxy.rs` 模块头那句「`download.proxy_mode` → 引擎的 HTTP 客户端 +(`http::build_client`)」**从 M295 起是真的**(此前是空头支票)。 + +两条实现注记(改这段代码前先读): + +* **宿主忘了读 = 编译不过**:两个键是 `RawTuning` 的字段,两个宿主的构造都是**完整字段字面量** + (没有 `..RawTuning::default()`),漏一个就是 `E0063`。编译器能拦「忘了读」,拦不住「读到了但 + 传空」——后者由 §6 的 M20 / M21 咬。 +* **`reqwest` 会替用户补 `http://`**:`Proxy::all("127.0.0.1:7890")` 是合法写法(少写 scheme 不算 + 「解析不了」)。真正解析不了的只剩端口越界(`…: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` 打 ``,`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:四类原因自己算,不摊 reqwest / url 的原文。** 两轮补丁的教训:第一版靠 +`Proxy::all` 的报错(只有一句 `builder error`),第二版靠 `url::ParseError` 的 Display(英文 +`invalid port number`,而且 reqwest 的宽口径会让「解析成功/失败」本身不可靠)——两种都还是把别人的 +原文当结论。现在 `validate()` 自己判四类,各类一句中文: + +| 情形 | 文案(接在「代理地址用不了(<地址>):」之后) | +|---|---| +| scheme 不是 http/https(`socks5://` 等) | `本构建不支持 socks5 代理(只支持 http / https)` | +| 缺主机名(`http://` / `http://:7890`) | `缺少主机名(host 是空的)` | +| 端口越界(`…:99999`)/ `0` / 空 | `端口 99999 不是 1–65535 之间的数字` / `端口 0 不是有效端口(要 1–65535)` / `端口是空的(host: 后面没有数字)` | +| 空地址 | `地址是空的(manual 档要填 http://host:port)` | + +`validate()` 放行但 reqwest 仍拒绝的残余情形(例如 userinfo 里有非法字符):中文结论在前,reqwest +原文只作为**括号里的证据**(`地址写法不被接受(…)`)。判据是「文案里命中该类中文原因,且不含 +`builder error` / `invalid port number`」——不是「`problem()` 返回了 Some」。 ## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原) @@ -185,6 +275,22 @@ reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是这 | M14(M294) | `shared::build` 的 `tune.apply_source_selection(region)` 换回 `apply_gh_proxy(region)` | `download::preference::tests::the_settings_reach_the_engines_candidate_plan` | 红(`left: ["bmclapi","official"] / right: ["official","bmclapi"]`,4 passed 1 failed) | | M15(M294) | `cli::download::tuning` 删掉 `.with_source_selection(..)` | `cli::download::tests::the_source_selection_settings_reach_the_cli_engine` | 红(`official 必须第一条`:`left: ["bmclapi","official"]`,0 passed 1 failed) | | M16(M294) | `app-iced` 的 `engine_with` 删掉 `selection.apply(profile)`(换回裸档案) | `…::downloads::engine::tests::the_source_selection_settings_reach_the_jdk_download_engine` | 红(`left: ["bmclapi","official"]`,0 passed 1 failed) | +| M17(M295) | `ProxySetting::apply` 的 manual 档删掉 `.proxy(proxy)` | `…::engine_mock::proxy::manual_mode_really_sends_the_request_through_the_proxy` | 红(`AllSourcesFailed`:`.invalid` 解析不了 ⇒ 请求根本没到假代理;2 passed 1 failed) | +| M18(M295) | `ProxySetting::apply` 的 `Direct` 档删掉 `.no_proxy()` | `…::engine_mock::proxy::direct_ignores_the_env_proxy_and_system_follows_it` | 红(direct 档在教育环境变量的假代理上拿到了正文:`[112, 114, …]` = `proxied payload`) | +| 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) | +| 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 单测 `proxy::tests::unsupported_schemes_and_bad_ports_explain_themselves`) | 红(两处都 panic:`代理地址: … InvalidPort`) | +| M23(M295 r1/r2) | `redact_proxy_url` 开头直接 `return raw.to_string()` | `download::proxy::tests::a_password_in_the_proxy_url_never_reaches_a_user_visible_string`(另两条同族一起红) | 红(`FAILED. 2 passed; 3 failed`)。红原文:`生效值行泄漏口令:… 代理=manual(http://user:PWMUSTNOTLEAK@127.0.0.1:7890) …`;socks 那条也一起漏(`代理地址用不了(socks5://user:PWMUSTNOTLEAK@…)`) | +| 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/r2) | `validate` 的 scheme 白名单写成 `if false`(= socks5 又变回「能用」) | `download::proxy::tests::unsupported_schemes_and_bad_ports_explain_themselves`(+ engine_mock 的 socks 用例) | 红(`FAILED. 4 passed; 1 failed`):`本构建用不了的 scheme 必须报错` | +| M26(M295 r2) | `validate` 的端口判定关掉(`if let Some(port) = None::<&str>`) | 同上 | 红(`FAILED. 4 passed; 1 failed`):`http://127.0.0.1:99999 ⇒ 代理地址用不了(…):地址写法不被接受(builder error:invalid port number)`——**没有自己的判定时,reqwest 的原文就会漏出来**(就是 r2 复审看到的那个形态) | +| M27(M295 r2) | `validate` 的「主机名为空」判定关掉 | 同上 | 红(`FAILED. 4 passed; 1 failed`):`http://:7890 ⇒ …地址写法不被接受(builder error:empty host)` | + +**M20 / M21 的一条附注**:直接把那两个字段**删掉**根本编译不过——两个宿主的 `RawTuning` 是完整字段 +字面量(没有 `..RawTuning::default()`),编译器会报 `E0063 missing fields proxy_mode and proxy_url`。 +所以「宿主忘了读这两个键」这一档是**编译期**拦住的;M20/M21 验的是「读到了但没用」(传 `None`), +那是编译器管不着的下一半。 ## 6.1 gh-proxy 那一行的形状(P3-1):M201 之后已解决 @@ -245,3 +351,27 @@ M193 时期这一组把「GitHub 加速前缀」的三态画成三行(`GitHub 两条 HTTP 路径发的是同一个字符串。证据只在**离线回环**上(发出去的字节 == `EngineConfig::default().user_agent`);**没有向 Modrinth / CurseForge 发过一条带新 UA 的真实请求**, 限流阈值、CF 会不会因为多了 `(+URL)` 而拒、Modrinth 是否会因此放宽,全部未知。 +* **代理只验到「明文 HTTP 真的经由代理」这一层**(M295):判据是本地假代理(`127.0.0.1` 的 + `TcpListener`)+ RFC 2606 的 `.invalid` 目标——目标域名自己解析不了,能拿到正文就只可能是因为 + 请求走了代理;代理记到的请求行是**绝对 URI**(`GET http://…/path`),那是「经由代理」的直接证据。 + **没验**: + * 真机上的「系统代理」:Windows 注册表 / macOS 网络偏好设置里的代理**没有被读**——本仓 reqwest + 没开 `client-proxy-system` feature ⇒ `system` 档只认环境变量(`HTTP_PROXY` 等)。所以「我在 + Windows 设置里开了系统代理」这件事对 `system` 档**无效**,这是已知限制,不是本页的承诺; + * PAC 脚本(`AutoConfigURL` / `PROXY.pac`)没测,代码里也没有任何 PAC 支持; + * 需要认证的代理(`Proxy-Authorization` / `Proxy::basic_auth`)没测;userinfo 里的账号口令 + 是否被 reqwest 转成 `Proxy-Authorization` 也没验(我们只保证**文案**里没有口令,见 §5.3); + * SOCKS 代理**不支持**(reqwest 的 `socks` feature 没开)。M295 r1 起它不再是「静默直连」: + `socks5://` 会被 scheme 白名单拦下有界错误并提示「本构建不支持 socks5 代理」(§5.3),但「能用 + SOCKS」这件事仍然没有; + * HTTPS 目标的 `CONNECT` 隧道没测:夹具只讲明文 HTTP,`manual` 档对 https 目标的行为只在 + 「`Proxy::all` 会拦下 https」这一层有依据; + * 代理自身连不上/半死时被归到 `Network` 还是 `Timeout`(以及会不会被 `attempts_for` 重试)没测; + * **没在真的代理软件上点过**(Clash / v2rayN / 公司代理),也没在「只靠 `HTTP_PROXY` 上网」的 + 环境里回归过 `none` 由「碰巧跟随环境变量」变成真直连这件事——那是一次真实的行为变化(见 §5.2)。 + * 「回落提示」只在 CLI 的真实二进制上端到端验过(`ull install --dry-run` 两行都不含口令); + **GUI 那一行只在单元层面验**(`group()` 产出的 `description` 文本),没在真窗口里看过它渲染、 + 折行、以及它与 424 列表单宽度的相容性(设置行的渲染在 `app-iced`,不在本页 scope)。 +* **发布版 GUI 的「下载 JDK」页仍不认代理两键**(M295):那一处是独立装配点,直接用 + `EngineConfig::default()`(= `system`,跟随环境变量)。它与「并发 / 三段时间」是同一条已知缺口 + (`app-iced/…/java/downloads/engine.rs` 模块头的对照表),要接就得把那一处并进 `wanted(cache_root)`。