Merge branch 'feat/m150-ghrsrc-implement-githubreleasesourc'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
c38fbe4068
7 files changed
+945
-18
No files matched your search
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! GitHub Releases 更新源(D50 定案:GitHub Releases 是唯一更新源)。
|
||||
//!
|
||||
//! # 地址形状(固定,不可配置)
|
||||
//!
|
||||
//! ```text
|
||||
//! 清单 <base>/<owner>/<repo>/releases/latest/download/manifest.json
|
||||
//! 产物 <base>/<owner>/<repo>/releases/latest/download/<清单里的产物文件名>
|
||||
//! ```
|
||||
//!
|
||||
//! `releases/latest/download/<asset>` 是 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<DownloadEngine>,
|
||||
owner: String,
|
||||
repo: String,
|
||||
base: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl GitHubReleaseSource {
|
||||
/// 用默认仓库([`DEFAULT_OWNER`] / [`DEFAULT_REPO`])与 GitHub 站点根地址构造。
|
||||
#[must_use]
|
||||
pub fn new(engine: Arc<DownloadEngine>) -> Self {
|
||||
Self::with_repo(engine, DEFAULT_OWNER, DEFAULT_REPO)
|
||||
}
|
||||
|
||||
/// 指定仓库构造(fork / 自建镜像仓、测试用)。
|
||||
#[must_use]
|
||||
pub fn with_repo(
|
||||
engine: Arc<DownloadEngine>,
|
||||
owner: impl Into<String>,
|
||||
repo: impl Into<String>,
|
||||
) -> 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:<port>`)以保持离线。
|
||||
#[must_use]
|
||||
pub fn with_base(mut self, base: impl Into<String>) -> 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<DownloadEngine> {
|
||||
&self.engine
|
||||
}
|
||||
|
||||
/// 某渠道的清单地址;非 stable 渠道没有清单(返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn manifest_url(&self, channel: Channel) -> Option<String> {
|
||||
(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<String, UpdateError> {
|
||||
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<Candidate> {
|
||||
// `dest` 只是 `DownloadRequest` 的必填字段,`plan` 不看它。
|
||||
let request = DownloadRequest::new(ResourceKind::GitHubRelease, url, PathBuf::new());
|
||||
self.engine.plan(&request, now_ms())
|
||||
}
|
||||
|
||||
/// 最新的 release 产物根路径(`<base>/<owner>/<repo>/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<Option<String>, UpdateError> {
|
||||
let candidates = self.candidates(url);
|
||||
if candidates.is_empty() {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::Network,
|
||||
format!("没有可用候选源:{url}"),
|
||||
));
|
||||
}
|
||||
let mut failure: Option<UpdateError> = 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<Option<String>, 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<Option<ReleaseManifest>, 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<u64, UpdateError> {
|
||||
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"))
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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://`),用途是测试、内网手工分发,
|
||||
//! 以及给远端实现当对拍基准。
|
||||
//!
|
||||
//! # 本地源的目录约定
|
||||
//!
|
||||
|
||||
@@ -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<DownloadEngine>, 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,
|
||||
"同一份清单,两个源选出的版本/产物/紧急度必须一致"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DownloadEngine> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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<std::sync::Mutex<Vec<String>>>,
|
||||
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String> {
|
||||
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",
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user