From f001b93942a734318de88ee94b2d1c49b32035b0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 7 Nov 2026 07:59:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20M150=20=E6=96=B0=E5=A2=9E=20GitHu?= =?UTF-8?q?b=20Releases=20=E6=9B=B4=E6=96=B0=E6=BA=90=EF=BC=88D50=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D50 定案 GitHub Releases 为唯一更新源,清单地址固定为 `releases/latest/download/manifest.json`。此前 update 模块只有对拍用的 DirSource,这次把真实源补上。 - `update::github::GitHubReleaseSource` 实现 `UpdateSource`:清单从 `///releases/latest/download/manifest.json` 取, 产物按 D49 命名规则(文件名)拼到同一条 latest/download 路径下;清单里 已写完整 http(s) 地址的原样使用。 - 网络全走 `core::download`,不新造栈:包体交给引擎 `download()`(镜像展开 → 地区判定 → 健康度排序 → 退避 → 续传 → 原子落盘),清单走引擎的 `plan()` + 共享 client 逐候选取文本(与 `game::java` 的一锤子 GET 同一手法)。 两条路径都吃到 mirrors.json 与地区档案里生效的 gh-proxy 前缀。 - 可配置面只有 owner/repo(默认 AskaEth/Unlucky-Launcher)与 base(默认 https://github.com,测试指向本地假服务);渠道只有 stable 有清单, beta/alpha/nightly 返回 Ok(None)(D50「先只发 stable」),清单 404 也算 「还没发布」而不是错误。 - `UpdateErrorKind` 新增 `Network`:远端取字节失败(连不上/超时/5xx/候选全挂) 与本地 IO 分开——前者可重试,后者重试无意义。引擎把逐源失败压成 AllSourcesFailed,本源从失败摘要里还原「所有源都 404」为 SourceMissing (产物没上传不该被报成网络抖动)。 - 校验仍只有一处:包体请求只带 expected_size,长度/摘要双检留在 `update::verify`(与 DirSource 同口径)。 - 单测 13 条,全程离线(假 HTTP 服务在 127.0.0.1):固定清单地址、产物 URL 解析、自定义 base/owner/repo、清单 404/非 JSON/字段非法/渠道不符/5xx 各自 归到白名单分类、产物下载、产物 404 → SourceMissing、镜像优先下 gh-proxy 前缀(清单与产物都验)、与 DirSource 同清单同产物选择结果一致。 --- crates/core/src/update/error.rs | 15 +- crates/core/src/update/github.rs | 342 ++++++++++++++++ crates/core/src/update/mod.rs | 15 +- crates/core/src/update/source.rs | 11 +- crates/core/src/update/tests/github_cases.rs | 387 +++++++++++++++++++ crates/core/src/update/tests/mod.rs | 6 +- crates/core/src/update/tests/support.rs | 187 ++++++++- 7 files changed, 945 insertions(+), 18 deletions(-) create mode 100644 crates/core/src/update/github.rs create mode 100644 crates/core/src/update/tests/github_cases.rs diff --git a/crates/core/src/update/error.rs b/crates/core/src/update/error.rs index a02ce17..65553f8 100644 --- a/crates/core/src/update/error.rs +++ b/crates/core/src/update/error.rs @@ -22,6 +22,11 @@ pub enum UpdateErrorKind { SourceMissing, /// 更新源协议不支持(例如本地目录源里出现了 `http://` 地址)。 UnsupportedSource, + /// 远端源取字节失败:连不上、超时、5xx、所有候选源(含镜像)全挂。 + /// + /// 与 [`Self::Io`] 分开是因为处置不同:网络失败值得隔一会儿重试/换镜像, + /// 本地 IO 失败(盘满、没权限)重试多少次都一样。 + Network, /// 本地 IO 失败:读源、写暂存、替换落盘。 Io, /// 摘要不符:拿到的字节与清单声明的不是同一份。 @@ -40,6 +45,7 @@ impl UpdateErrorKind { Self::ChannelMismatch => "channel_mismatch", Self::SourceMissing => "source_missing", Self::UnsupportedSource => "unsupported_source", + Self::Network => "network", Self::Io => "io", Self::DigestMismatch => "digest_mismatch", Self::SizeMismatch => "size_mismatch", @@ -48,11 +54,14 @@ impl UpdateErrorKind { /// 换个源 / 重下一次是否可能变好(调用方据此决定重试,别在这里写死重试次数)。 /// - /// 清单本身不合法、协议不支持、目标平台无权写盘这三类换多少源都一样, - /// 所以只有字节层(摘要/长度)与 IO(可能是瞬时占用)算可重试。 + /// 清单本身不合法、协议不支持、渠道不符这几类换多少源都一样,所以只有字节层 + /// (摘要/长度)、网络(可能是瞬时抖动)与 IO(可能是瞬时占用)算可重试。 #[must_use] pub const fn is_retryable(self) -> bool { - matches!(self, Self::DigestMismatch | Self::SizeMismatch | Self::Io) + matches!( + self, + Self::DigestMismatch | Self::SizeMismatch | Self::Network | Self::Io + ) } } diff --git a/crates/core/src/update/github.rs b/crates/core/src/update/github.rs new file mode 100644 index 0000000..72ae052 --- /dev/null +++ b/crates/core/src/update/github.rs @@ -0,0 +1,342 @@ +//! GitHub Releases 更新源(D50 定案:GitHub Releases 是唯一更新源)。 +//! +//! # 地址形状(固定,不可配置) +//! +//! ```text +//! 清单 ///releases/latest/download/manifest.json +//! 产物 ///releases/latest/download/<清单里的产物文件名> +//! ``` +//! +//! `releases/latest/download/` 是 GitHub 原生的「最新发布」地址:不用知道 tag、 +//! 不用调 API(API 有速率限制、也要 token),发布侧只要把 `manifest.json` 和各平台产物 +//! 挂在最新 release 上,客户端就能取到。清单里的 `url` 因此照 D49 的产物命名规则写成 +//! **文件名**(如 `unlucky-launcher_0.6.0_windows_x64.exe`),由本源拼成上面的地址; +//! 清单若已经写了完整 `http(s)` 地址(例如临时挂到别处复测),原样使用。 +//! +//! # 可配置面只有三个 +//! +//! * `owner` / `repo`(默认 [`DEFAULT_OWNER`] / [`DEFAULT_REPO`]); +//! * `base`(默认 [`DEFAULT_BASE`];**只为测试存在**——把它指向本地假 HTTP 服务, +//! 单测就不必联网)。 +//! +//! # 网络全走 `core::download` +//! +//! * **产物**(几十 MB 的包体):整段交给 [`DownloadEngine::download`]——镜像展开 +//! (国内 gh-proxy 前缀)、地区判定、健康度排序、失败退避、断点续传、原子落盘全部复用, +//! 本源不再造一套网络栈,也不自己拼镜像地址; +//! * **清单**(几 KB 的 JSON):用引擎的候选规划 [`GitHubReleaseSource::candidates`] + +//! 共享客户端 [`DownloadEngine::client`] 逐候选取文本,与 `core::game::java` 的 +//! 「一锤子 GET」同一手法。包体才需要退避/续传/分块,为几 KB 建一条下载流水线不划算。 +//! +//! 两种路径都吃到同一条镜像策略:`mirrors.json` 的前缀规则 + 地区档案里生效的 +//! `gh_proxy` 前缀([`crate::download::mirrors::MirrorTable::gh_proxy_candidates`]), +//! 国内环境下 `https://github.com/...` 会被改写成 `https://gh-proxy.com/https://github.com/...`。 +//! +//! # 渠道 +//! +//! `releases/latest` 只有一个,所以本源**只服务 stable**。beta/alpha/nightly 的清单枚举 +//! 保留但不发布(D50「先只发 stable」),因此非 stable 渠道一律 `Ok(None)` = 没有更新—— +//! 这不是错误,别让「还没开通道」变成用户机器上的红字。 +//! +//! # 校验 +//! +//! 本源只管「把字节放到 dest」:长度与摘要由 [`super::verify`] 统一双检(与 [`super::DirSource`] +//! 一致)。所以包体请求只带 `expected_size`(给进度预算与门户页假阳性兜底),不带摘要—— +//! 摘要判据只有一处,别在这里再写一套。 +//! +//! # 运行时 +//! +//! [`UpdateSource`] 是同步签名,本源在方法内部用 [`crate::download::block_on`] 桥接异步引擎 +//! (CLI 与 presenter 都是这么用的)。因此**不要在已有的 tokio 运行时里直接调本源的 +//! `fetch_*`**:tokio 不允许嵌套 `block_on`,会 panic。更新流程请放在独立线程上跑。 + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::download::cache::now_ms; +use crate::download::{ + Candidate, DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind, +}; + +use super::channel::Channel; +use super::error::{UpdateError, UpdateErrorKind}; +use super::manifest::{Artifact, ReleaseManifest}; +use super::source::UpdateSource; + +/// 默认仓库 owner。 +pub const DEFAULT_OWNER: &str = "AskaEth"; +/// 默认仓库名。 +pub const DEFAULT_REPO: &str = "Unlucky-Launcher"; +/// GitHub 站点根地址(`base` 的默认值)。 +pub const DEFAULT_BASE: &str = "https://github.com"; +/// 清单文件名(挂在最新 release 的产物里,地址由 D50 定死)。 +pub const MANIFEST_ASSET: &str = "manifest.json"; + +/// GitHub Releases 更新源。 +#[derive(Debug, Clone)] +pub struct GitHubReleaseSource { + engine: Arc, + owner: String, + repo: String, + base: String, + id: String, +} + +impl GitHubReleaseSource { + /// 用默认仓库([`DEFAULT_OWNER`] / [`DEFAULT_REPO`])与 GitHub 站点根地址构造。 + #[must_use] + pub fn new(engine: Arc) -> Self { + Self::with_repo(engine, DEFAULT_OWNER, DEFAULT_REPO) + } + + /// 指定仓库构造(fork / 自建镜像仓、测试用)。 + #[must_use] + pub fn with_repo( + engine: Arc, + owner: impl Into, + repo: impl Into, + ) -> Self { + let owner = owner.into(); + let repo = repo.into(); + let id = format!("github:{owner}/{repo}"); + Self { + engine, + owner, + repo, + base: DEFAULT_BASE.to_string(), + id, + } + } + + /// 换 `base`(站点根地址,末尾斜杠会去掉)。 + /// + /// 生产不用这个口子;单测把它指向本地假服务(`http://127.0.0.1:`)以保持离线。 + #[must_use] + pub fn with_base(mut self, base: impl Into) -> Self { + self.base = base.into().trim_end_matches('/').to_string(); + self + } + + /// 仓库 owner。 + #[must_use] + pub fn owner(&self) -> &str { + &self.owner + } + + /// 仓库名。 + #[must_use] + pub fn repo(&self) -> &str { + &self.repo + } + + /// 站点根地址。 + #[must_use] + pub fn base(&self) -> &str { + &self.base + } + + /// 引擎(调用方要挂进度事件时用)。 + #[must_use] + pub fn engine(&self) -> &Arc { + &self.engine + } + + /// 某渠道的清单地址;非 stable 渠道没有清单(返回 `None`)。 + #[must_use] + pub fn manifest_url(&self, channel: Channel) -> Option { + (channel == Channel::Stable).then(|| format!("{}/{MANIFEST_ASSET}", self.download_base())) + } + + /// 把清单里的 `url` 解析成真实下载地址。 + /// + /// 已经是 `http(s)://` 的地址原样使用(清单可以临时指向别处);其余一律当成 + /// 「最新 release 里的产物文件名」,拼到本源的下载根路径下。 + /// + /// # Errors + /// + /// `url` 为空是 [`UpdateErrorKind::InvalidManifest`](清单坏,不是源的问题); + /// 非 http(s) 的其它协议是 [`UpdateErrorKind::UnsupportedSource`]。 + pub fn artifact_url(&self, artifact: &Artifact) -> Result { + let raw = artifact.url.trim(); + if raw.is_empty() { + return Err(UpdateError::invalid_manifest("产物的 url 为空")); + } + if raw.starts_with("http://") || raw.starts_with("https://") { + return Ok(raw.to_string()); + } + if raw.contains("://") { + return Err(UpdateError::new( + UpdateErrorKind::UnsupportedSource, + format!("GitHub 源只认 http(s) 地址或 release 内的产物文件名:`{raw}`"), + )); + } + // `./name` 与 `/name` 都当成「release 里的文件名」,别把整串拼出个双斜杠路径。 + let name = raw.trim_start_matches(['.', '/']); + Ok(format!("{}/{name}", self.download_base())) + } + + /// 某地址在引擎眼里的候选源序列(镜像展开 + 健康度排序,**不发请求**)。 + /// + /// 生产调用走引擎自己的规划;这里是给「镜像策略是否生效」这类断言用的观察窗。 + #[must_use] + pub fn candidates(&self, url: &str) -> Vec { + // `dest` 只是 `DownloadRequest` 的必填字段,`plan` 不看它。 + let request = DownloadRequest::new(ResourceKind::GitHubRelease, url, PathBuf::new()); + self.engine.plan(&request, now_ms()) + } + + /// 最新的 release 产物根路径(`///releases/latest/download`)。 + fn download_base(&self) -> String { + format!( + "{}/{}/{}/releases/latest/download", + self.base, self.owner, self.repo + ) + } + + /// 逐候选取一段文本:`Ok(None)` = 所有候选都说「这份文件不存在」。 + fn fetch_text(&self, url: &str) -> Result, UpdateError> { + let candidates = self.candidates(url); + if candidates.is_empty() { + return Err(UpdateError::new( + UpdateErrorKind::Network, + format!("没有可用候选源:{url}"), + )); + } + let mut failure: Option = None; + let mut all_missing = true; + for candidate in candidates { + match self.get_candidate_text(&candidate.url) { + Ok(Some(text)) => return Ok(Some(text)), + // 某个候选 404 不代表没有:镜像可能比官方慢半拍,继续问下一个。 + Ok(None) => {} + Err(error) => { + all_missing = false; + failure = Some(error); + } + } + } + if all_missing { + Ok(None) + } else { + Err(failure.unwrap_or_else(|| { + UpdateError::new(UpdateErrorKind::Network, format!("取 {url} 失败")) + })) + } + } + + /// 单个候选地址取文本;`Ok(None)` = 404/410(该候选没有这份文件)。 + fn get_candidate_text(&self, url: &str) -> Result, UpdateError> { + let client = self.engine.client(); + crate::download::block_on(async { + let response = client.get(url).send().await.map_err(|error| { + UpdateError::new( + UpdateErrorKind::Network, + format!("取更新清单失败:{error}({url})"), + ) + })?; + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE { + return Ok(None); + } + if !status.is_success() { + return Err(UpdateError::new( + UpdateErrorKind::Network, + format!("更新清单返回 HTTP {status}({url})"), + )); + } + let text = response.text().await.map_err(|error| { + UpdateError::new( + UpdateErrorKind::Network, + format!("读更新清单响应失败:{error}({url})"), + ) + })?; + if text.trim().is_empty() { + return Err(UpdateError::invalid_manifest(format!( + "清单响应为空({url}),发布脚本可能漏传了 {MANIFEST_ASSET}" + ))); + } + Ok(Some(text)) + }) + } +} + +impl UpdateSource for GitHubReleaseSource { + fn source_id(&self) -> &str { + &self.id + } + + fn fetch_manifest(&self, channel: Channel) -> Result, UpdateError> { + let Some(url) = self.manifest_url(channel) else { + return Ok(None); + }; + let Some(text) = self.fetch_text(&url)? else { + return Ok(None); + }; + let manifest = ReleaseManifest::from_json(&text).map_err(|mut error| { + error.message = format!("{}(来源 {url})", error.message); + error + })?; + if manifest.channel != channel { + return Err(UpdateError::new( + UpdateErrorKind::ChannelMismatch, + format!( + "请求的是 {channel} 渠道,清单 {url} 自报 {}", + manifest.channel + ), + )); + } + Ok(Some(manifest)) + } + + fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result { + let url = self.artifact_url(artifact)?; + if let Some(parent) = dest.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .map_err(|error| UpdateError::io_at(parent, &error))?; + } + } + // 摘要不在这里传:`update::verify` 统一做长度/摘要双检(trait 契约,别各写一套)。 + let request = DownloadRequest::new(ResourceKind::GitHubRelease, url.clone(), dest) + .with_size(artifact.size); + let outcome = crate::download::block_on(self.engine.download(request)) + .map_err(|error| download_error(&error, &url))?; + Ok(outcome.bytes) + } +} + +/// 把下载引擎的错误翻译成自更新的错误分类(两套分类各有各的职责,别混用)。 +fn download_error(error: &DownloadError, url: &str) -> UpdateError { + let kind = match error.kind { + DownloadErrorKind::NotFound => UpdateErrorKind::SourceMissing, + DownloadErrorKind::Integrity => UpdateErrorKind::DigestMismatch, + DownloadErrorKind::Io => UpdateErrorKind::Io, + DownloadErrorKind::InvalidRequest => UpdateErrorKind::UnsupportedSource, + DownloadErrorKind::AllSourcesFailed if all_candidates_missing(error) => { + UpdateErrorKind::SourceMissing + } + DownloadErrorKind::ServerError + | DownloadErrorKind::Timeout + | DownloadErrorKind::Network + | DownloadErrorKind::RangeUnsupported + | DownloadErrorKind::AllSourcesFailed => UpdateErrorKind::Network, + }; + let mut message = format!("{}({url})", error.message); + if !error.suppressed.is_empty() { + message.push_str(&format!(";各源:{}", error.suppressed.join(";"))); + } + UpdateError::new(kind, message) +} + +/// 所有候选是不是都回「这份文件不存在」。 +/// +/// 引擎把逐源失败压成一条 [`DownloadErrorKind::AllSourcesFailed`],原始的 404/410 分类只剩在 +/// 失败摘要文本里(`download::http::classify_status` 的文案:`HTTP 404`)。不还原这一层, +/// 「发布清单里挂了个没上传的产物」会被报成「网络失败 + 可重试」,重试多少次都一样。 +fn all_candidates_missing(error: &DownloadError) -> bool { + !error.suppressed.is_empty() + && error + .suppressed + .iter() + .all(|line| line.contains("HTTP 404") || line.contains("HTTP 410")) +} diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs index 373466c..0b5738b 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -1,8 +1,9 @@ //! 自更新内核:版本比较、发布清单、渠道准入、下载校验、原子替换。 //! -//! 用户已定的主线是「安装包 + 自动更新通道」,但**更新源还没拍板**。所以这一层只做与源无关 -//! 的部分,源本身是插拔的 [`UpdateSource`]:core 里没有任何 CDN 常量、没有任何厂商名, -//! 接哪家由上层注入(本地目录源 [`DirSource`] 是内置的对照/测试实现)。 +//! 更新源已定(D50):**GitHub Releases 是唯一更新源**,真实实现在 +//! [`GitHubReleaseSource`](清单固定地址 + 产物走 `core::download` 引擎)。 +//! 这一层仍然保持源无关:核心只认 [`UpdateSource`] 三个方法,接哪家由上层注入 +//! (本地目录源 [`DirSource`] 留作对拍基准与内网手工分发)。 //! //! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2 //! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。 @@ -23,9 +24,9 @@ //! ```text //! version 语义化版本解析与排序(alpha/beta/rc、build metadata) channel 渠道阶梯与准入 //! manifest 清单模型 + JSON 校验 target 目标平台与包形态 -//! source UpdateSource trait + 本地目录源 verify 暂存 + 长度/摘要校验 -//! checker 选择哪一份发布、急不急 install 原子替换 + 回滚 -//! error 分类与文案 +//! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50) +//! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急 +//! install 原子替换 + 回滚 error 分类与文案 //! ``` //! //! # 刻意不做的事 @@ -40,6 +41,7 @@ pub mod channel; pub mod checker; pub mod error; +pub mod github; pub mod install; pub mod manifest; pub mod source; @@ -53,6 +55,7 @@ pub use crate::platform::os_info::OsFamily; pub use self::channel::Channel; pub use self::checker::{CheckOutcome, UpdateCandidate, UpdateChecker, Urgency}; pub use self::error::{UpdateError, UpdateErrorKind}; +pub use self::github::GitHubReleaseSource; pub use self::install::{ backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy, }; diff --git a/crates/core/src/update/source.rs b/crates/core/src/update/source.rs index 6029853..f79a69d 100644 --- a/crates/core/src/update/source.rs +++ b/crates/core/src/update/source.rs @@ -1,12 +1,11 @@ //! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。 //! -//! 更新源(CDN / 对象存储 / GitHub Release / 内网文件服务器)**还没拍板**,所以这里 -//! 刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层 -//! 注入。将来接 HTTP 源时,实现里用 `tokio`/`reqwest` 或 -//! [`crate::download::blocking::block_on`] 都行,trait 形状不用动。 +//! 这一层刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层 +//! 注入。真实源已定(D50):[`super::GitHubReleaseSource`],它在 `github.rs`,网络与镜像策略 +//! 全交给 `crate::download`。 //! -//! 内置的实现只有一个:[`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发、 -//! 以及给将来的 HTTP 实现当对照物。 +//! 内置的另一个实现是 [`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发, +//! 以及给远端实现当对拍基准。 //! //! # 本地源的目录约定 //! diff --git a/crates/core/src/update/tests/github_cases.rs b/crates/core/src/update/tests/github_cases.rs new file mode 100644 index 0000000..b15cbbb --- /dev/null +++ b/crates/core/src/update/tests/github_cases.rs @@ -0,0 +1,387 @@ +//! GitHub Releases 源:固定清单地址、产物 URL 解析、渠道只有 stable、与 `DirSource` 对拍。 +//! +//! 全程离线:假 HTTP 服务起在 `127.0.0.1` 上,`base` 指向它——没有一个用例访问 github.com +//! 或 gh-proxy(那部分只能真联网验收,见 mission 报告的「没验的部分」)。 + +use std::fs; +use std::sync::Arc; + +use crate::download::mirrors::SOURCE_GH_PROXY; +use crate::download::{DownloadEngine, RegionProfile, SOURCE_OFFICIAL}; +use crate::update::github::{DEFAULT_BASE, DEFAULT_OWNER, DEFAULT_REPO, MANIFEST_ASSET}; +use crate::update::{ + Channel, DirSource, GitHubReleaseSource, ReleaseManifest, Target, UpdateChecker, UpdateError, + UpdateErrorKind, UpdateSource, Version, +}; + +use super::support::{artifact, engine_for, manifest, release, FakeHttp, FakeResponse, TempDir}; + +const PAYLOAD: &[u8] = b"github release payload"; + +/// 假服务上清单所在路径(与本源拼出来的地址逐字符对齐)。 +fn manifest_path() -> String { + format!("/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}") +} + +/// 按 D49 命名规则的产物文件名。 +fn asset_name() -> &'static str { + "unlucky-launcher_0.6.0_windows_x64.exe" +} + +/// 假服务上产物所在路径。 +fn asset_path() -> String { + format!( + "/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{}", + asset_name() + ) +} + +/// 一份可用的 stable 清单(产物 url 就是 D49 文件名)。 +fn stable_manifest_json() -> String { + manifest( + "stable", + &[release( + "0.6.0", + r#","notes":"修掉启动崩溃""#, + &[artifact("win-x64", "portable-exe", asset_name(), PAYLOAD)], + )], + ) +} + +/// 解析上面那份清单(多数字段级断言都用它)。 +fn parsed_stable() -> ReleaseManifest { + ReleaseManifest::from_json(&stable_manifest_json()).expect("清单应可解析") +} + +fn target() -> Target { + Target::parse("win-x64").expect("目标平台写法合法") +} + +fn source_with(engine: Arc, base: &str) -> GitHubReleaseSource { + GitHubReleaseSource::new(engine).with_base(base) +} + +/// 本地假服务 + 指向它的源(直连模式:不套 gh-proxy,请求落在 127.0.0.1 上)。 +fn local_source(tmp: &TempDir, server: &FakeHttp) -> GitHubReleaseSource { + source_with(engine_for(tmp, RegionProfile::direct(0)), &server.base()) +} + +#[test] +fn manifest_url_is_the_fixed_latest_download_path() { + let tmp = TempDir::new("gh-url"); + let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0))); + assert_eq!(source.owner(), DEFAULT_OWNER); + assert_eq!(source.repo(), DEFAULT_REPO); + assert_eq!(source.base(), DEFAULT_BASE); + let expected = format!( + "{DEFAULT_BASE}/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{MANIFEST_ASSET}" + ); + assert_eq!( + source.manifest_url(Channel::Stable).as_deref(), + Some(expected.as_str()), + "D50 定死的固定地址,一个字符都不许飘" + ); + assert_eq!( + source.source_id(), + format!("github:{DEFAULT_OWNER}/{DEFAULT_REPO}") + ); + + // fork / 自建镜像仓:owner/repo 可换,路径形状不变。 + let fork = GitHubReleaseSource::with_repo( + engine_for(&tmp, RegionProfile::direct(0)), + "someone", + "ull-fork", + ) + .with_base("http://127.0.0.1:1/"); + assert_eq!( + fork.manifest_url(Channel::Stable).as_deref(), + Some("http://127.0.0.1:1/someone/ull-fork/releases/latest/download/manifest.json"), + "base 末尾斜杠要去掉,别拼出双斜杠" + ); + assert_eq!(fork.source_id(), "github:someone/ull-fork"); +} + +#[test] +fn channels_other_than_stable_publish_nothing() { + let tmp = TempDir::new("gh-channels"); + let server = FakeHttp::start(vec![( + manifest_path().as_str(), + FakeResponse::json(&stable_manifest_json()), + )]); + let source = local_source(&tmp, &server); + + for channel in [Channel::Beta, Channel::Alpha, Channel::Nightly] { + assert!( + source.manifest_url(channel).is_none(), + "{channel} 不该有清单地址" + ); + let manifest = source.fetch_manifest(channel).expect("没开通道不是错误"); + assert!( + manifest.is_none(), + "{channel} 应返回 Ok(None)(不发布即无更新)" + ); + } + assert!( + server.hits().is_empty(), + "非 stable 渠道一次请求都不该发(实际:{:?})", + server.hits() + ); +} + +#[test] +fn artifact_url_appends_the_release_download_path() { + let tmp = TempDir::new("gh-artifact-url"); + let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0))); + let artifact = &parsed_stable().releases[0].artifacts[0]; + + let url = source.artifact_url(artifact).expect("应能拼出地址"); + assert_eq!( + url, + format!( + "{DEFAULT_BASE}/{DEFAULT_OWNER}/{DEFAULT_REPO}/releases/latest/download/{}", + asset_name() + ) + ); + + // `./name` / `/name` 与裸文件名等价(清单是人手写的)。 + for written in [format!("./{}", asset_name()), format!("/{}", asset_name())] { + let mut tweaked = artifact.clone(); + tweaked.url = written; + assert_eq!(source.artifact_url(&tweaked).expect("应可解析"), url); + } + + // 已经写了完整地址 → 原样使用(清单可以临时指向别处)。 + let mut absolute = artifact.clone(); + absolute.url = "https://example.invalid/dist/ull.exe".to_string(); + assert_eq!( + source.artifact_url(&absolute).expect("绝对地址原样"), + "https://example.invalid/dist/ull.exe" + ); + + // 其它协议不认(那是别的源的活)。 + let mut unsupported = artifact.clone(); + unsupported.url = "ftp://example.invalid/ull.exe".to_string(); + assert_eq!( + source + .artifact_url(&unsupported) + .expect_err("ftp 不是本源能取的") + .kind, + UpdateErrorKind::UnsupportedSource + ); +} + +#[test] +fn fetches_and_parses_the_manifest_from_the_base() { + let tmp = TempDir::new("gh-manifest-ok"); + let server = FakeHttp::start(vec![( + manifest_path().as_str(), + FakeResponse::json(&stable_manifest_json()), + )]); + let source = local_source(&tmp, &server); + + let manifest = source + .fetch_manifest(Channel::Stable) + .expect("应取到清单") + .expect("清单存在"); + assert_eq!(manifest.channel, Channel::Stable); + assert_eq!(manifest.releases.len(), 1); + assert_eq!(manifest.releases[0].version.to_string(), "0.6.0"); + assert_eq!(server.hits(), vec![manifest_path()]); +} + +#[test] +fn missing_manifest_in_the_release_is_not_an_error() { + // 最新 release 没挂 manifest.json → 404 → 当成「还没发布」,不是红字。 + let tmp = TempDir::new("gh-manifest-404"); + let server = FakeHttp::start(vec![]); + let source = local_source(&tmp, &server); + + let outcome = source + .fetch_manifest(Channel::Stable) + .expect("404 不是错误"); + assert!(outcome.is_none(), "取不到清单 = 还没发过版"); + assert_eq!( + server.hits(), + vec![manifest_path()], + "确实问过一次,不是压根没发请求" + ); +} + +#[test] +fn broken_json_is_reported_as_invalid_manifest() { + let tmp = TempDir::new("gh-manifest-broken"); + let server = FakeHttp::start(vec![( + manifest_path().as_str(), + FakeResponse::json("{ not json"), + )]); + let source = local_source(&tmp, &server); + + let error = source + .fetch_manifest(Channel::Stable) + .expect_err("坏 JSON 必须报错"); + assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); + assert!( + error.message.contains(&server.base()), + "错误里要带出来源地址,日志才定位得到(实际:{})", + error.message + ); + assert!(!error.is_retryable(), "清单坏重试多少次都一样"); +} + +#[test] +fn illegal_manifest_fields_are_reported() { + let tmp = TempDir::new("gh-manifest-illegal"); + // size 为 0(发布脚本写漏)——字段层面的非法,走同一条 InvalidManifest 分类。 + let json = r#"{"channel":"stable","releases":[{"version":"0.6.0","artifacts":[{"target":"win-x64","format":"portable-exe","url":"ull.exe","size":0,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}]}"#; + let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::json(json))]); + let source = local_source(&tmp, &server); + + let error = source + .fetch_manifest(Channel::Stable) + .expect_err("size 为 0 必须报错"); + assert_eq!(error.kind, UpdateErrorKind::InvalidManifest); +} + +#[test] +fn manifest_self_reporting_another_channel_is_a_mismatch() { + let tmp = TempDir::new("gh-manifest-mismatch"); + let server = FakeHttp::start(vec![( + manifest_path().as_str(), + FakeResponse::json(&manifest("beta", &[])), + )]); + let source = local_source(&tmp, &server); + + let error = source + .fetch_manifest(Channel::Stable) + .expect_err("渠道不符必须报错"); + assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch); +} + +#[test] +fn server_errors_are_network_errors() { + let tmp = TempDir::new("gh-manifest-500"); + let server = FakeHttp::start(vec![(manifest_path().as_str(), FakeResponse::status(500))]); + let source = local_source(&tmp, &server); + + let error = source + .fetch_manifest(Channel::Stable) + .expect_err("5xx 必须报错"); + assert_eq!(error.kind, UpdateErrorKind::Network); + assert!(error.is_retryable(), "网络抖动值得隔一会儿再试"); +} + +#[test] +fn downloads_the_artifact_through_the_engine() { + let tmp = TempDir::new("gh-artifact-download"); + let server = FakeHttp::start(vec![(asset_path().as_str(), FakeResponse::bin(PAYLOAD))]); + let source = local_source(&tmp, &server); + let dest = tmp.join("staging/package.exe"); + + let written = source + .fetch_artifact(&parsed_stable().releases[0].artifacts[0], &dest) + .expect("应下载成功"); + assert_eq!(written, PAYLOAD.len() as u64); + assert_eq!(fs::read(&dest).expect("目标文件应存在"), PAYLOAD); + assert_eq!( + server.hits(), + vec![asset_path()], + "包体走引擎,请求地址就是解析出来的那条" + ); +} + +#[test] +fn missing_artifact_is_reported_as_source_missing() { + let tmp = TempDir::new("gh-artifact-404"); + let server = FakeHttp::start(vec![]); + let source = local_source(&tmp, &server); + + let error: UpdateError = source + .fetch_artifact( + &parsed_stable().releases[0].artifacts[0], + &tmp.join("staging/package.exe"), + ) + .expect_err("产物不存在必须报错"); + assert_eq!(error.kind, UpdateErrorKind::SourceMissing); + assert!(!error.is_retryable(), "产物没上传,重试多少次都一样"); +} + +#[test] +fn mirror_first_region_puts_the_gh_proxy_in_front() { + // 镜像策略不新造:国内(镜像优先)环境下 gh-proxy 前缀由引擎的镜像表 + 地区档案决定。 + let tmp = TempDir::new("gh-mirror"); + let source = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::mirror_first(0))); + let artifact_url = source + .artifact_url(&parsed_stable().releases[0].artifacts[0]) + .expect("应能拼出地址"); + + let planned = source.candidates(&artifact_url); + assert!( + planned.len() >= 2, + "镜像优先至少要有镜像与官方两条候选(实际 {planned:?})" + ); + assert_eq!(planned[0].source, SOURCE_GH_PROXY); + assert_eq!( + planned[0].url, + format!("https://gh-proxy.com/{artifact_url}") + ); + assert!( + planned + .iter() + .any(|candidate| candidate.source == SOURCE_OFFICIAL && candidate.url == artifact_url), + "官方地址仍要留作兜底(实际 {planned:?})" + ); + + // 直连模式不套前缀:只剩官方一条。 + let direct = GitHubReleaseSource::new(engine_for(&tmp, RegionProfile::direct(0))); + let planned_direct = direct.candidates(&artifact_url); + assert_eq!(planned_direct.len(), 1); + assert_eq!(planned_direct[0].source, SOURCE_OFFICIAL); + + // 清单地址同样吃到 gh-proxy(国内拿不到 releases/latest/download 的正是清单这一刻)。 + let manifest_url = source.manifest_url(Channel::Stable).expect("stable 有清单"); + let planned_manifest = source.candidates(&manifest_url); + assert_eq!(planned_manifest[0].source, SOURCE_GH_PROXY); + assert_eq!( + planned_manifest[0].url, + format!("https://gh-proxy.com/{manifest_url}") + ); +} + +#[test] +fn agrees_with_dir_source_on_the_same_manifest() { + // 对拍:同一份清单、同一个当前版本、同一个目标平台,两个源的判定必须一模一样。 + let tmp = TempDir::new("gh-dir-parity"); + let json = stable_manifest_json(); + + // 本地目录源:清单与产物都在临时目录里(产物 url 是相对文件名)。 + tmp.write( + &format!("{}.json", Channel::Stable.as_str()), + json.as_bytes(), + ); + tmp.write(asset_name(), PAYLOAD); + let dir = DirSource::new(tmp.path()); + + // GitHub 源:同一份 JSON 由假服务提供,产物 url 解析成 release 产物地址。 + let server = FakeHttp::start(vec![ + (manifest_path().as_str(), FakeResponse::json(&json)), + (asset_path().as_str(), FakeResponse::bin(PAYLOAD)), + ]); + let github = local_source(&tmp, &server); + + let current = Version::parse("0.5.0").expect("版本号合法"); + let from_dir = UpdateChecker::new(dir, target()) + .check(¤t, Channel::Stable) + .expect("本地源应能判定"); + let from_github = UpdateChecker::new(github, target()) + .check(¤t, Channel::Stable) + .expect("GitHub 源应能判定"); + + assert!( + from_dir.is_available(), + "基准用例应当有可用更新(实际 {from_dir:?})" + ); + assert_eq!( + from_github, from_dir, + "同一份清单,两个源选出的版本/产物/紧急度必须一致" + ); +} diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index b26497b..275079c 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -7,12 +7,14 @@ //! * 渠道准入:阶梯比较 + 预发布阶段表; //! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留; //! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用; -//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝。 +//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝; +//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。 //! -//! 全程离线:所有字节都在临时目录里造,没有一个用例发网络请求。 +//! 全程离线:所有字节都在临时目录或 127.0.0.1 的假 HTTP 服务上造,没有一个用例发公网请求。 mod channel_cases; mod checker_cases; +mod github_cases; mod install_cases; mod manifest_cases; mod source_cases; diff --git a/crates/core/src/update/tests/support.rs b/crates/core/src/update/tests/support.rs index a66e13c..7847076 100644 --- a/crates/core/src/update/tests/support.rs +++ b/crates/core/src/update/tests/support.rs @@ -8,7 +8,10 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use crate::download::verify::digest_bytes; -use crate::download::HashAlgo; +use crate::download::{ + ContentCache, DownloadEngine, EngineConfig, HashAlgo, HealthRegistry, MirrorTable, + RegionProfile, +}; /// 一次性临时目录(`Drop` 时尽力删除;删不掉也留在系统 temp 下不碍事)。 pub(crate) struct TempDir { @@ -80,3 +83,185 @@ pub(crate) fn manifest(channel: &str, releases: &[String]) -> String { releases.join(",") ) } + +/// 造一台测试用下载引擎:镜像表用内置清单,健康度只在内存里,缓存落临时目录。 +/// +/// `region` 决定「官方优先」还是「镜像优先」——gh-proxy 前缀只在该是后一种时生效。 +pub(crate) fn engine_for(tmp: &TempDir, region: RegionProfile) -> std::sync::Arc { + std::sync::Arc::new(DownloadEngine::new( + EngineConfig { + // 失败用例不必等三次退避。 + max_attempts_per_source: 1, + ..EngineConfig::default() + }, + std::sync::Arc::new(MirrorTable::builtin()), + std::sync::Arc::new(region), + std::sync::Arc::new(HealthRegistry::in_memory()), + std::sync::Arc::new(ContentCache::open(tmp.join("cache"))), + )) +} + +/// 假服务的一条路由应答。 +#[derive(Debug, Clone)] +pub(crate) struct FakeResponse { + pub(crate) status: u16, + pub(crate) content_type: &'static str, + pub(crate) body: Vec, +} + +impl FakeResponse { + /// 200 + JSON 体。 + pub(crate) fn json(body: &str) -> Self { + Self { + status: 200, + content_type: "application/json", + body: body.as_bytes().to_vec(), + } + } + + /// 200 + 二进制体(模拟 release 里的产物)。 + pub(crate) fn bin(body: &[u8]) -> Self { + Self { + status: 200, + content_type: "application/octet-stream", + body: body.to_vec(), + } + } + + /// 固定状态码 + 空体(404 / 500 用)。 + pub(crate) fn status(status: u16) -> Self { + Self { + status, + content_type: "text/plain", + body: Vec::new(), + } + } +} + +/// 进程内极简 HTTP/1.1 假服务:按**完整请求路径**查表应答,未命中的路径一律 404。 +/// +/// 用它而不是 httpmock:项目里下载测试就是这么干的(零额外依赖),而且能把 +/// 「请求真的发到了哪个 URL」记下来给断言用。 +pub(crate) struct FakeHttp { + addr: std::net::SocketAddr, + hits: std::sync::Arc>>, + stop: std::sync::Arc, + handle: Option>, +} + +impl FakeHttp { + /// 起服务(`routes` 是「路径 → 应答」表)。 + pub(crate) fn start(routes: Vec<(&str, FakeResponse)>) -> Self { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("绑定本地端口"); + let addr = listener.local_addr().expect("取本地地址"); + let routes: Vec<(String, FakeResponse)> = routes + .into_iter() + .map(|(path, response)| (path.to_string(), response)) + .collect(); + let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let worker_hits = std::sync::Arc::clone(&hits); + let worker_stop = std::sync::Arc::clone(&stop); + let handle = std::thread::spawn(move || { + for stream in listener.incoming() { + if worker_stop.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + let Ok(mut stream) = stream else { break }; + let Some(path) = read_request_path(&mut stream) else { + continue; + }; + worker_hits + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(path.clone()); + let response = routes.iter().find(|(route, _)| route == &path).map_or_else( + || FakeResponse::status(404), + |(_, response)| response.clone(), + ); + let _ = write_response(&mut stream, &response); + } + }); + + Self { + addr, + hits, + stop, + handle: Some(handle), + } + } + + /// 站点根地址(可直接喂给 `GitHubReleaseSource::with_base`)。 + pub(crate) fn base(&self) -> String { + format!("http://{}", self.addr) + } + + /// 已经收到的请求路径(按到达顺序)。 + pub(crate) fn hits(&self) -> Vec { + self.hits + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } +} + +impl Drop for FakeHttp { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + // 自己连一下唤醒阻塞中的 accept,否则 join 会一直等。 + let _ = std::net::TcpStream::connect(self.addr); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +/// 读一个请求的路径(只读头部,够用)。 +fn read_request_path(stream: &mut std::net::TcpStream) -> Option { + use std::io::Read; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let read = stream.read(&mut chunk).ok()?; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let head = String::from_utf8_lossy(&buffer).to_string(); + head.lines() + .next()? + .split_whitespace() + .nth(1) + .map(str::to_string) +} + +fn write_response( + stream: &mut std::net::TcpStream, + response: &FakeResponse, +) -> std::io::Result<()> { + use std::io::Write; + let head = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response.status, + reason(response.status), + response.content_type, + response.body.len() + ); + stream.write_all(head.as_bytes())?; + stream.write_all(&response.body)?; + stream.flush() +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Status", + } +}