Merge branch 'feat/m146-updcore-update-core-without-binding'
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
57082219ea
21 files changed
+3309
No files matched your search
@@ -22,3 +22,4 @@ pub mod game;
|
||||
pub mod launch;
|
||||
pub mod platform;
|
||||
pub mod setting;
|
||||
pub mod update;
|
||||
@@ -0,0 +1,113 @@
|
||||
//! 更新渠道(通道)与准入规则。
|
||||
//!
|
||||
//! 四个渠道是一条**稳定度阶梯**,`Ord` 的派生顺序就是阶梯顺序
|
||||
//! (`Stable < Beta < Alpha < Nightly`),所以「本渠道收不收那份发布」只要一次比较:
|
||||
//! `manifest_channel <= requested_channel`。nightly 收全部,stable 只收 stable。
|
||||
//!
|
||||
//! 除了清单自报的渠道,还要看版本号里的预发布阶段([`Stage`]):清单可以撒谎
|
||||
//! (`channel: "stable"` 但版本是 `1.0.0-alpha.2`),阶段这条判据不信清单只信版本号本身。
|
||||
//! 两道判据**同时**成立才准入,见 [`Channel::admits`] 与 [`Channel::admits_stage`]。
|
||||
//!
|
||||
//! 准入阶梯(与渠道顺序同一套口径,别在别处另立一张表):
|
||||
//!
|
||||
//! | 渠道 | 收正式版 | 收 rc | 收 beta | 收 alpha | 收认不出的预发布 |
|
||||
//! | ---- | -------- | ----- | ------- | -------- | ---------------- |
|
||||
//! | stable | ✓ | | | | |
|
||||
//! | beta | ✓ | ✓ | | | |
|
||||
//! | alpha | ✓ | ✓ | ✓ | | |
|
||||
//! | nightly | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::version::Stage;
|
||||
|
||||
/// 更新渠道。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Channel {
|
||||
/// 稳定版(默认渠道)。
|
||||
Stable,
|
||||
/// 公测版。
|
||||
Beta,
|
||||
/// 内测版。
|
||||
Alpha,
|
||||
/// 每日构建(收所有预发布,包括认不出的标识)。
|
||||
Nightly,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
/// 全部渠道,从最稳到最激进。
|
||||
pub const ALL: [Self; 4] = [Self::Stable, Self::Beta, Self::Alpha, Self::Nightly];
|
||||
|
||||
/// 清单文件名与日志用的稳定字符串 id(**不要改名**:清单里的 `channel` 字段就是它)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Stable => "stable",
|
||||
Self::Beta => "beta",
|
||||
Self::Alpha => "alpha",
|
||||
Self::Nightly => "nightly",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析渠道名(大小写与首尾空白不敏感)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 认不出的名字返回 [`UpdateErrorKind::InvalidManifest`](清单里的 `channel` 写错了)。
|
||||
pub fn parse(text: &str) -> Result<Self, UpdateError> {
|
||||
let name = text.trim().to_ascii_lowercase();
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|channel| channel.as_str() == name)
|
||||
.ok_or_else(|| {
|
||||
let allowed = Self::ALL
|
||||
.iter()
|
||||
.map(|channel| channel.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
UpdateError::new(
|
||||
UpdateErrorKind::InvalidManifest,
|
||||
format!("未知更新渠道 `{text}`(只认 {allowed})"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 本渠道是否接受「发布自报 `release` 渠道」的那份版本。
|
||||
///
|
||||
/// 判定就是阶梯比较(`Ord` 的派生顺序即阶梯),不在别处另立一张表。
|
||||
#[must_use]
|
||||
pub fn admits(self, release: Self) -> bool {
|
||||
release <= self
|
||||
}
|
||||
|
||||
/// 本渠道是否接受该预发布阶段(正式版对所有渠道都成立)。
|
||||
#[must_use]
|
||||
pub const fn admits_stage(self, stage: Stage) -> bool {
|
||||
matches!(
|
||||
(self, stage),
|
||||
(Self::Stable, Stage::Stable)
|
||||
| (Self::Beta, Stage::Stable | Stage::ReleaseCandidate)
|
||||
| (
|
||||
Self::Alpha,
|
||||
Stage::Stable | Stage::ReleaseCandidate | Stage::Beta
|
||||
)
|
||||
| (Self::Nightly, _)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Channel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Channel {
|
||||
type Err = UpdateError;
|
||||
|
||||
fn from_str(text: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 检查更新:把「清单 + 当前版本 + 渠道 + 目标平台」收敛成一个结论。
|
||||
//!
|
||||
//! 判定顺序(每一步都是纯函数,可单测):
|
||||
//!
|
||||
//! 1. 取清单(源没有该渠道清单 = 还没发过版 = 已是最新);
|
||||
//! 2. 清单自报渠道与请求渠道不符 → [`UpdateErrorKind::ChannelMismatch`](拿错文件是源的问题,
|
||||
//! 不能悄悄当成「没有更新」);
|
||||
//! 3. 准入过滤:[`Channel::admits`](清单渠道阶梯)+ [`Channel::admits_stage`](版本号里的
|
||||
//! 预发布阶段)**同时**成立。清单可以撒谎,版本号不会;
|
||||
//! 4. 只留版本号**严格大于**当前版本的候选(降级不做,nightly 想回退请手工下载);
|
||||
//! 5. 按版本号从高到低找**第一个有本平台产物**的候选:命中就是 [`CheckOutcome::Available`],
|
||||
//! 一个都没有则 [`CheckOutcome::MissingArtifact`](有新版但没给你的平台发包,这是发布侧的
|
||||
//! 漏项,不能对用户说「已是最新」);
|
||||
//! 6. 候选为空 → [`CheckOutcome::UpToDate`]。
|
||||
//!
|
||||
//! 强制更新([`Urgency::Forced`])有两个来源:发布自带的 `force`,或当前版本低于该发布的
|
||||
//! `min_supported`(发布侧收回老版本支持)。两者都不看渠道——强制就是强制。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::channel::Channel;
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::manifest::{Artifact, Release, ReleaseManifest};
|
||||
use super::source::UpdateSource;
|
||||
use super::target::Target;
|
||||
use super::verify::{stage, StagedPackage};
|
||||
use super::version::Version;
|
||||
|
||||
/// 这次更新急不急。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Urgency {
|
||||
/// 可选:用户想升再升。
|
||||
Optional,
|
||||
/// 强制:`force` 或当前版本低于发布侧的最低支持版本。
|
||||
Forced,
|
||||
}
|
||||
|
||||
impl Urgency {
|
||||
/// 稳定字符串 id(事件流用)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Optional => "optional",
|
||||
Self::Forced => "forced",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个可安装的候选:哪个发布、哪个产物、急不急。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateCandidate {
|
||||
/// 发布信息(版本、说明、产物列表)。
|
||||
pub release: Release,
|
||||
/// 本平台要装的那个产物。
|
||||
pub artifact: Artifact,
|
||||
/// 急不急。
|
||||
pub urgency: Urgency,
|
||||
}
|
||||
|
||||
/// 检查更新的结论。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CheckOutcome {
|
||||
/// 已是最新。
|
||||
UpToDate {
|
||||
/// 当前版本。
|
||||
current: Version,
|
||||
/// 该渠道内准入的最高版本(清单里没有可用发布时为 `None`)。
|
||||
latest: Option<Version>,
|
||||
},
|
||||
/// 有可装的更新。
|
||||
Available(Box<UpdateCandidate>),
|
||||
/// 有更高的版本,但里面没有本平台的产物。
|
||||
MissingArtifact {
|
||||
/// 那个更高版本(没有本平台产物的那份发布)。
|
||||
release: Box<Release>,
|
||||
},
|
||||
}
|
||||
|
||||
impl CheckOutcome {
|
||||
/// 是否有可装的更新。
|
||||
#[must_use]
|
||||
pub const fn is_available(&self) -> bool {
|
||||
matches!(self, Self::Available(_))
|
||||
}
|
||||
|
||||
/// 可安装候选(没有则为 `None`)。
|
||||
#[must_use]
|
||||
pub fn candidate(&self) -> Option<&UpdateCandidate> {
|
||||
match self {
|
||||
Self::Available(candidate) => Some(candidate),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查更新的入口:绑定一个源与一个目标平台,之后每次检查只传「当前版本 + 渠道」。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateChecker<S> {
|
||||
source: S,
|
||||
target: Target,
|
||||
}
|
||||
|
||||
impl<S: UpdateSource> UpdateChecker<S> {
|
||||
/// 用显式目标平台构造(跨平台代查、测试都靠它)。
|
||||
#[must_use]
|
||||
pub fn new(source: S, target: Target) -> Self {
|
||||
Self { source, target }
|
||||
}
|
||||
|
||||
/// 用当前编译目标构造。
|
||||
#[must_use]
|
||||
pub fn for_current_target(source: S) -> Self {
|
||||
Self::new(source, Target::current())
|
||||
}
|
||||
|
||||
/// 绑定的源。
|
||||
#[must_use]
|
||||
pub const fn source(&self) -> &S {
|
||||
&self.source
|
||||
}
|
||||
|
||||
/// 绑定的目标平台。
|
||||
#[must_use]
|
||||
pub const fn target(&self) -> Target {
|
||||
self.target
|
||||
}
|
||||
|
||||
/// 查一次更新。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 清单取不到、清单不合法、渠道不符都返回 [`UpdateError`]。
|
||||
/// 「没有更新」不是错误,走 [`CheckOutcome::UpToDate`]。
|
||||
pub fn check(&self, current: &Version, channel: Channel) -> Result<CheckOutcome, UpdateError> {
|
||||
let Some(manifest) = self.source.fetch_manifest(channel)? else {
|
||||
return Ok(CheckOutcome::UpToDate {
|
||||
current: current.clone(),
|
||||
latest: None,
|
||||
});
|
||||
};
|
||||
if manifest.channel != channel {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::ChannelMismatch,
|
||||
format!("请求的是 {channel} 渠道,清单自报 {}", manifest.channel),
|
||||
));
|
||||
}
|
||||
let admitted = admitted(&manifest, channel).collect::<Vec<_>>();
|
||||
let latest = admitted.iter().map(|release| release.version.clone()).max();
|
||||
let mut candidates = admitted
|
||||
.into_iter()
|
||||
.filter(|release| release.version > *current)
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort_by(|left, right| right.version.cmp(&left.version));
|
||||
|
||||
for release in &candidates {
|
||||
if let Some(artifact) = release.artifact_for(self.target) {
|
||||
return Ok(CheckOutcome::Available(Box::new(UpdateCandidate {
|
||||
urgency: urgency_of(release, current),
|
||||
release: (*release).clone(),
|
||||
artifact: artifact.clone(),
|
||||
})));
|
||||
}
|
||||
}
|
||||
match candidates.first() {
|
||||
Some(release) => Ok(CheckOutcome::MissingArtifact {
|
||||
release: Box::new((*release).clone()),
|
||||
}),
|
||||
None => Ok(CheckOutcome::UpToDate {
|
||||
current: current.clone(),
|
||||
latest,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把候选的包体取下来、落暂存、校验。成功返回的 [`StagedPackage`] 可直接交给
|
||||
/// [`super::install::install_staged`]。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 取包失败透传;长度或摘要不符分别返回 `SizeMismatch` / `DigestMismatch`,
|
||||
/// 且暂存文件会被删掉(见 [`stage`])。
|
||||
pub fn prepare(
|
||||
&self,
|
||||
candidate: &UpdateCandidate,
|
||||
staging_dir: &Path,
|
||||
) -> Result<StagedPackage, UpdateError> {
|
||||
stage(&self.source, &candidate.artifact, staging_dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// 该渠道准入的全部发布(不看版本高低)。
|
||||
fn admitted(manifest: &ReleaseManifest, channel: Channel) -> impl Iterator<Item = &Release> {
|
||||
manifest.releases.iter().filter(move |release| {
|
||||
channel.admits(release.channel) && channel.admits_stage(release.version.stage())
|
||||
})
|
||||
}
|
||||
|
||||
fn urgency_of(release: &Release, current: &Version) -> Urgency {
|
||||
if release.force || release.below_min_supported(current) {
|
||||
Urgency::Forced
|
||||
} else {
|
||||
Urgency::Optional
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 自更新错误:一份分类 + 一句人话 + 出错路径。
|
||||
//!
|
||||
//! 分类只服务三件事:给日志/事件一个稳定 id、判「要不要换源」、判「用户的包有没有被动过」。
|
||||
//! core 不引 `thiserror`,手写 `Display`/`Error`(与 `download::error` 同一取舍)。
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::version::VersionError;
|
||||
|
||||
/// 自更新失败分类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum UpdateErrorKind {
|
||||
/// 版本号解析失败(清单里写了不是 semver 的东西)。
|
||||
InvalidVersion,
|
||||
/// 清单结构不合法:缺字段、摘要格式错、同一清单里重复版本、`size` 为 0。
|
||||
InvalidManifest,
|
||||
/// 清单声明的渠道与请求的渠道不符(拿 stable 清单当 beta 用)。
|
||||
ChannelMismatch,
|
||||
/// 更新源没有这份数据:渠道清单文件、产物文件不存在。
|
||||
SourceMissing,
|
||||
/// 更新源协议不支持(例如本地目录源里出现了 `http://` 地址)。
|
||||
UnsupportedSource,
|
||||
/// 本地 IO 失败:读源、写暂存、替换落盘。
|
||||
Io,
|
||||
/// 摘要不符:拿到的字节与清单声明的不是同一份。
|
||||
DigestMismatch,
|
||||
/// 长度不符:典型成因是下载被截断,也可能清单写错。
|
||||
SizeMismatch,
|
||||
}
|
||||
|
||||
impl UpdateErrorKind {
|
||||
/// 稳定字符串 id(日志、事件流、诊断规则输入用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidVersion => "invalid_version",
|
||||
Self::InvalidManifest => "invalid_manifest",
|
||||
Self::ChannelMismatch => "channel_mismatch",
|
||||
Self::SourceMissing => "source_missing",
|
||||
Self::UnsupportedSource => "unsupported_source",
|
||||
Self::Io => "io",
|
||||
Self::DigestMismatch => "digest_mismatch",
|
||||
Self::SizeMismatch => "size_mismatch",
|
||||
}
|
||||
}
|
||||
|
||||
/// 换个源 / 重下一次是否可能变好(调用方据此决定重试,别在这里写死重试次数)。
|
||||
///
|
||||
/// 清单本身不合法、协议不支持、目标平台无权写盘这三类换多少源都一样,
|
||||
/// 所以只有字节层(摘要/长度)与 IO(可能是瞬时占用)算可重试。
|
||||
#[must_use]
|
||||
pub const fn is_retryable(self) -> bool {
|
||||
matches!(self, Self::DigestMismatch | Self::SizeMismatch | Self::Io)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自更新错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateError {
|
||||
/// 分类。
|
||||
pub kind: UpdateErrorKind,
|
||||
/// 人类可读描述(进日志;用户可见文案由上层按 kind 组织,别直接贴这里)。
|
||||
pub message: String,
|
||||
/// 出错的文件或目录(能定位到就带上)。
|
||||
pub path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl UpdateError {
|
||||
/// 构造错误。
|
||||
#[must_use]
|
||||
pub fn new(kind: UpdateErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 清单不合法。
|
||||
#[must_use]
|
||||
pub fn invalid_manifest(message: impl Into<String>) -> Self {
|
||||
Self::new(UpdateErrorKind::InvalidManifest, message)
|
||||
}
|
||||
|
||||
/// 标注出错路径。
|
||||
#[must_use]
|
||||
pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.path = Some(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 分类是否可重试。
|
||||
#[must_use]
|
||||
pub const fn is_retryable(&self) -> bool {
|
||||
self.kind.is_retryable()
|
||||
}
|
||||
|
||||
/// 把底层 IO 错误包成「某个路径上的 IO 失败」。
|
||||
#[must_use]
|
||||
pub fn io_at(path: &Path, source: &io::Error) -> Self {
|
||||
Self::new(UpdateErrorKind::Io, source.to_string()).with_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UpdateError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.path {
|
||||
Some(path) => write!(
|
||||
f,
|
||||
"[{}] {}({})",
|
||||
self.kind.as_str(),
|
||||
self.message,
|
||||
path.display()
|
||||
),
|
||||
None => write!(f, "[{}] {}", self.kind.as_str(), self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UpdateError {}
|
||||
|
||||
impl From<VersionError> for UpdateError {
|
||||
fn from(error: VersionError) -> Self {
|
||||
Self::new(UpdateErrorKind::InvalidVersion, error.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! 原子替换:先把旧文件改名挪开,再把新文件搬进来;任一步失败都能回滚。
|
||||
//!
|
||||
//! # 为什么不用「直接覆盖」
|
||||
//!
|
||||
//! Windows 上**正在运行的 exe** 不能被覆盖、也不能被删除(映像被占用,改字节这条路根本
|
||||
//! 不存在);`std::fs::rename` 走 `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`,目标只要被任何
|
||||
//! 「不共享删除」的句柄占着就报共享冲突(`os error 32`)。剩下唯一可用的动作是**改名**:
|
||||
//! 把旧文件挪到同目录的备份名,让新文件顶替原来的路径。而改名本身也可能被拒绝(杀软扫描
|
||||
//! 句柄、编辑器打开、另一个进程正在写),所以失败必须能回到旧版本——这正是本模块存在的理由。
|
||||
//!
|
||||
//! 三步写法,Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后,
|
||||
//! 回滚路径只有 Windows 被真正跑过,最容易坏的那一半就没人测了):
|
||||
//!
|
||||
//! ```text
|
||||
//! 1. dest 存在 → rename(dest → <同目录备份>) 旧版本先安全落地
|
||||
//! 2. rename(暂存包 → dest) 新版本就位
|
||||
//! 3a. 成功 → 返回 InstallReceipt(备份留着,等新版真的能起来再删)
|
||||
//! 3b. 失败 → rename(备份 → dest) 回滚,并把「回滚是否成功」如实报给调用方
|
||||
//! ```
|
||||
//!
|
||||
//! # 本模块**不**负责的事
|
||||
//!
|
||||
//! * **不判断现在能不能替换**。正在跑的 exe 只能由「退出自身 → 由另一个进程搬运」这条
|
||||
//! 路换掉(HMCL 的 `--apply-to` 模式,见 analyse/07 §4.3)。这里只保证搬一半失败能回滚。
|
||||
//! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器,
|
||||
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
|
||||
//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再
|
||||
//! [`InstallReceipt::remove_backup`])。
|
||||
//!
|
||||
//! # 单测覆盖到哪
|
||||
//!
|
||||
//! 占用场景是**真的造出来**的:测试里用 `share_mode(FILE_SHARE_READ)` 打开目标文件
|
||||
//! (**不带** `FILE_SHARE_DELETE`,与 Windows 加载器打开正在运行的映像时一致),
|
||||
//! 让 `rename` 撞共享冲突,于是在 Windows 上能看到「重试 → 仍失败 → 旧版本完好」。
|
||||
//! 那条用例是 `#[cfg(windows)]`:Unix 允许改名打开中的文件,本来就该成功。
|
||||
//!
|
||||
//! 但它不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。
|
||||
//! 另外注意:`std::fs::File::open` **默认共享删除**,用它造的「占用」不生效,
|
||||
//! 写这类用例时必须显式指定 `share_mode`。
|
||||
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::error::UpdateError;
|
||||
use super::verify::StagedPackage;
|
||||
|
||||
/// 同进程内备份文件名去重序号。
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// 替换策略:改名失败时重试几次、每次间隔多久。
|
||||
///
|
||||
/// 默认 5 次 × 80ms:足够扛过杀软扫描/索引器短暂占用,又不至于让用户在「更新」按钮上
|
||||
/// 干等半分钟。真正长期的占用(另一个启动器实例在跑)重试多少次都没用,直接报错更诚实。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReplacePolicy {
|
||||
/// 总尝试次数(含首次),`0` 按 `1` 处理。
|
||||
pub attempts: u32,
|
||||
/// 两次尝试之间的等待。
|
||||
pub delay: Duration,
|
||||
}
|
||||
|
||||
impl Default for ReplacePolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
attempts: 5,
|
||||
delay: Duration::from_millis(80),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplacePolicy {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub const fn new(attempts: u32, delay: Duration) -> Self {
|
||||
Self { attempts, delay }
|
||||
}
|
||||
}
|
||||
|
||||
/// 替换成功后的收据。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InstallReceipt {
|
||||
/// 新版本所在的路径。
|
||||
pub dest: PathBuf,
|
||||
/// 旧版本被挪到哪(原本没有文件时为 `None`)。
|
||||
pub backup: Option<PathBuf>,
|
||||
/// 新版本字节数。
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
impl InstallReceipt {
|
||||
/// 删掉旧版本备份(新版启动成功之后调用)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 删不掉(还占着、没权限)返回带路径的 [`UpdateError`]——这时**别**当成更新失败,
|
||||
/// 只是留了个垃圾文件,下次启动还能再删。
|
||||
pub fn remove_backup(&self) -> Result<(), UpdateError> {
|
||||
let Some(backup) = &self.backup else {
|
||||
return Ok(());
|
||||
};
|
||||
fs::remove_file(backup).map_err(|error| UpdateError::io_at(backup, &error))
|
||||
}
|
||||
|
||||
/// 手动回滚:把备份搬回 `dest`(新版起不来时用)。成功后 `backup` 字段清空。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 没有备份时是空操作 `Ok(())`;搬不动返回 [`UpdateError`](此时 `backup` 保持不变,
|
||||
/// 旧版本还在原位置,可以再试)。
|
||||
pub fn rollback(&mut self, policy: ReplacePolicy) -> Result<(), UpdateError> {
|
||||
let Some(backup) = self.backup.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
rename_with_retry(backup, &self.dest, policy)
|
||||
.map_err(|error| UpdateError::io_at(&self.dest, &error))?;
|
||||
self.backup = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 替换失败:原始错误 + 回滚结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InstallFailure {
|
||||
/// 原始失败。
|
||||
pub error: UpdateError,
|
||||
/// 旧版本是否已回到原路径(`true` = 目标没被破坏;本来没有旧文件时也算 `true`)。
|
||||
pub rolled_back: bool,
|
||||
/// 回滚失败时旧版本留在哪(`None` = 已回到原位或压根没有旧文件)。
|
||||
pub backup_left: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl InstallFailure {
|
||||
/// 目标是否可能处于「既不是旧版也不是新版」的坏状态(需要人工介入)。
|
||||
#[must_use]
|
||||
pub const fn needs_manual_repair(&self) -> bool {
|
||||
!self.rolled_back
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for InstallFailure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.error)?;
|
||||
if self.rolled_back {
|
||||
f.write_str("(已回滚,旧版本仍在原位)")
|
||||
} else {
|
||||
let where_ = self.backup_left.as_ref().map_or_else(String::new, |path| {
|
||||
format!(",旧文件留在 {}", path.display())
|
||||
});
|
||||
write!(f, "(回滚失败,需要人工处理{where_})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InstallFailure {}
|
||||
|
||||
/// 把校验过的暂存包装到 `dest`;失败则回滚,旧版本保持可用。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 目录建不出来、目标被占用、改名失败都返回 [`InstallFailure`],
|
||||
/// 其中的 `rolled_back` 如实反映旧版本有没有回到原位。
|
||||
pub fn install_staged(
|
||||
staged: &StagedPackage,
|
||||
dest: &Path,
|
||||
policy: ReplacePolicy,
|
||||
) -> Result<InstallReceipt, InstallFailure> {
|
||||
if let Some(parent) = dest
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| failed(UpdateError::io_at(parent, &error), true, None))?;
|
||||
}
|
||||
|
||||
let mut backup = None;
|
||||
if fs::symlink_metadata(dest).is_ok() {
|
||||
let path = backup_path(dest);
|
||||
rename_with_retry(dest, &path, policy)
|
||||
.map_err(|error| failed(UpdateError::io_at(dest, &error), true, None))?;
|
||||
backup = Some(path);
|
||||
}
|
||||
|
||||
match rename_with_retry(staged.path(), dest, policy) {
|
||||
Ok(()) => Ok(InstallReceipt {
|
||||
dest: dest.to_path_buf(),
|
||||
backup,
|
||||
bytes: staged.size(),
|
||||
}),
|
||||
Err(error) => {
|
||||
let error = UpdateError::io_at(dest, &error);
|
||||
let Some(backup_path) = backup else {
|
||||
// 目标原本不存在:没有旧版本要恢复,目标也没被写坏,
|
||||
// 所以按「旧版本仍在原位」上报(新安装失败 ≠ 现有安装被破坏)。
|
||||
return Err(failed(error, true, None));
|
||||
};
|
||||
match rename_with_retry(&backup_path, dest, policy) {
|
||||
Ok(()) => Err(failed(error, true, None)),
|
||||
Err(_) => Err(failed(error, false, Some(backup_path))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(error: UpdateError, rolled_back: bool, backup_left: Option<PathBuf>) -> InstallFailure {
|
||||
InstallFailure {
|
||||
error,
|
||||
rolled_back,
|
||||
backup_left,
|
||||
}
|
||||
}
|
||||
|
||||
/// 旧版本的备份路径:`<dest 同目录>/.<文件名>.ullold-<pid>-<seq>`。
|
||||
///
|
||||
/// 点号开头在 Unix 上是隐藏文件,也和 `download` 的 `.ullpart-*` 中间文件一个家族;
|
||||
/// 必须同目录,因为 `rename` **不能跨卷**(Unix 返回 `EXDEV`,Windows 返回
|
||||
/// `ERROR_NOT_SAME_DEVICE`——`std` 的 `fs::rename` 不带 `MOVEFILE_COPY_ALLOWED`,
|
||||
/// 不会偷偷降级成复制)。
|
||||
#[must_use]
|
||||
pub fn backup_path(dest: &Path) -> PathBuf {
|
||||
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let name = dest.file_name().map_or_else(
|
||||
|| String::from("ull"),
|
||||
|name| name.to_string_lossy().into_owned(),
|
||||
);
|
||||
let backup = format!(".{name}.ullold-{}-{seq}", std::process::id());
|
||||
match dest.parent() {
|
||||
Some(parent) => parent.join(backup),
|
||||
None => PathBuf::from(backup),
|
||||
}
|
||||
}
|
||||
|
||||
/// 改名,失败时按策略重试(占用类错误才值得重试)。
|
||||
fn rename_with_retry(from: &Path, to: &Path, policy: ReplacePolicy) -> io::Result<()> {
|
||||
let attempts = policy.attempts.max(1);
|
||||
let mut last = None;
|
||||
for attempt in 0..attempts {
|
||||
match fs::rename(from, to) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
let retryable = is_transient(&error) && attempt + 1 < attempts;
|
||||
if !retryable {
|
||||
return Err(error);
|
||||
}
|
||||
std::thread::sleep(policy.delay);
|
||||
last = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last.unwrap_or_else(|| io::Error::other("rename 未执行")))
|
||||
}
|
||||
|
||||
/// 是否属于「等一会儿可能就好了」的失败。
|
||||
///
|
||||
/// Windows 的共享冲突(`os error 32`)落在 `ErrorKind::Other` 里(`std` 没有专门的 kind),
|
||||
/// 所以这里必须把 `Other` 也算上;`PermissionDenied` 覆盖 `ACCESS_DENIED`(杀软持有的句柄)。
|
||||
fn is_transient(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::PermissionDenied
|
||||
| io::ErrorKind::Other
|
||||
| io::ErrorKind::Interrupted
|
||||
| io::ErrorKind::WouldBlock
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! 发布清单模型:一个渠道一份 JSON,列出该渠道已知的所有发布与各平台的产物。
|
||||
//!
|
||||
//! 字段形状取自 `DevDocs/analyse/07-settings-packaging-update-p2.md` §6.2 的扩展清单,
|
||||
//! 但**只留与更新源无关的部分**:清单里没有任何 CDN 常量,`url` 是字符串,怎么解析由
|
||||
//! [`crate::update::UpdateSource`] 的实现决定。
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "channel": "stable",
|
||||
//! "releases": [
|
||||
//! {
|
||||
//! "version": "0.3.0",
|
||||
//! "channel": "stable",
|
||||
//! "released_at": "2026-01-02T03:04:05Z",
|
||||
//! "notes": "修掉启动崩溃",
|
||||
//! "force": false,
|
||||
//! "min_supported": "0.2.0",
|
||||
//! "artifacts": [
|
||||
//! {"target": "win-x64", "format": "portable-zip", "url": "ull-0.3.0-win-x64.zip",
|
||||
//! "size": 22100000, "sha256": "…"}
|
||||
//! ]
|
||||
//! }
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! 读的方向刻意严格(`deny_unknown_fields` + 逐字段校验):清单是我们自己发的东西,
|
||||
//! 拼错的字段名宁可在 CI 上炸掉,也别在用户机器上变成「查不到更新」。
|
||||
//!
|
||||
//! # 校验规则(`from_json` 会一次性查完)
|
||||
//!
|
||||
//! * `version` / `min_supported` 必须是合法 semver;
|
||||
//! * 同一份清单里 `version` 不许重复(重复意味着「升到哪个」没有确定答案);
|
||||
//! * `size` 必须 > 0(0 字节的包只可能是发布脚本写漏了);
|
||||
//! * 每个产物必须带 `sha256` 或 `sha1`,且长度/字符集合法(两者都带时以 `sha256` 为准);
|
||||
//! * `url` 不许为空。
|
||||
//!
|
||||
//! `released_at` / `notes` 只做展示,不解析成时间类型(core 不引时间库,排序只看版本号)。
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::download::Integrity;
|
||||
|
||||
use super::channel::Channel;
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::target::{PackageFormat, Target};
|
||||
use super::version::Version;
|
||||
|
||||
/// 一个渠道的发布清单。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReleaseManifest {
|
||||
/// 这份清单属于哪个渠道。
|
||||
pub channel: Channel,
|
||||
/// 已发布的版本(顺序无所谓:选版本的是 checker,不是清单的书写顺序)。
|
||||
pub releases: Vec<Release>,
|
||||
}
|
||||
|
||||
impl ReleaseManifest {
|
||||
/// 解析清单 JSON 并做完整校验。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// JSON 语法错、字段名拼错、版本号/摘要/长度不合法都返回 [`UpdateError`],
|
||||
/// 分类是 [`UpdateErrorKind::InvalidManifest`] 或 [`UpdateErrorKind::InvalidVersion`]。
|
||||
pub fn from_json(text: &str) -> Result<Self, UpdateError> {
|
||||
let raw: RawManifest = serde_json::from_str(text).map_err(|error| {
|
||||
UpdateError::invalid_manifest(format!("清单 JSON 解析失败:{error}"))
|
||||
})?;
|
||||
let channel = Channel::parse(&raw.channel)?;
|
||||
let mut releases = Vec::with_capacity(raw.releases.len());
|
||||
for raw_release in raw.releases {
|
||||
let release = Release::from_raw(channel, raw_release)?;
|
||||
if releases
|
||||
.iter()
|
||||
.any(|existing: &Release| existing.version == release.version)
|
||||
{
|
||||
return Err(UpdateError::invalid_manifest(format!(
|
||||
"清单 {} 里版本 {} 出现多次",
|
||||
channel, release.version
|
||||
)));
|
||||
}
|
||||
releases.push(release);
|
||||
}
|
||||
Ok(Self { channel, releases })
|
||||
}
|
||||
|
||||
/// 清单里该目标平台的最高可用版本(不看渠道准入,纯查表;选择逻辑在 checker)。
|
||||
#[must_use]
|
||||
pub fn newest_artifact_for(&self, target: Target) -> Option<(&Release, &Artifact)> {
|
||||
self.releases
|
||||
.iter()
|
||||
.filter_map(|release| {
|
||||
release
|
||||
.artifact_for(target)
|
||||
.map(|artifact| (release, artifact))
|
||||
})
|
||||
.max_by(|left, right| left.0.version.cmp(&right.0.version))
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次发布。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Release {
|
||||
/// 版本号。
|
||||
pub version: Version,
|
||||
/// 这次发布自报的渠道(可以比清单渠道更稳,例如 beta 清单里放一个 stable 补丁版)。
|
||||
pub channel: Channel,
|
||||
/// 发布时间(RFC3339 字符串,仅展示)。
|
||||
pub released_at: Option<String>,
|
||||
/// 更新说明(纯文本,仅展示)。
|
||||
pub notes: Option<String>,
|
||||
/// 是否强制更新(不看版本号也得升)。
|
||||
pub force: bool,
|
||||
/// 客户端最低支持版本:当前版本低于它就必须升(发布侧收回老版本的支持)。
|
||||
pub min_supported: Option<Version>,
|
||||
/// 各平台的产物。清单里的顺序 = 同一目标多形态时的优先级。
|
||||
pub artifacts: Vec<Artifact>,
|
||||
}
|
||||
|
||||
impl Release {
|
||||
/// 该目标平台的首选产物(清单顺序即优先级;一个都没有就返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn artifact_for(&self, target: Target) -> Option<&Artifact> {
|
||||
self.artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.target == target)
|
||||
}
|
||||
|
||||
/// 当前版本是否低于本发布要求的最低支持版本。
|
||||
#[must_use]
|
||||
pub fn below_min_supported(&self, current: &Version) -> bool {
|
||||
self.min_supported
|
||||
.as_ref()
|
||||
.is_some_and(|minimum| current < minimum)
|
||||
}
|
||||
|
||||
fn from_raw(manifest_channel: Channel, raw: RawRelease) -> Result<Self, UpdateError> {
|
||||
let version = Version::parse(&raw.version)?;
|
||||
let channel = match &raw.channel {
|
||||
Some(text) => Channel::parse(text)?,
|
||||
None => manifest_channel,
|
||||
};
|
||||
let min_supported = raw
|
||||
.min_supported
|
||||
.as_deref()
|
||||
.map(Version::parse)
|
||||
.transpose()?;
|
||||
let mut artifacts = Vec::with_capacity(raw.artifacts.len());
|
||||
for raw_artifact in raw.artifacts {
|
||||
artifacts.push(Artifact::from_raw(&version, raw_artifact)?);
|
||||
}
|
||||
Ok(Self {
|
||||
version,
|
||||
channel,
|
||||
released_at: raw.released_at,
|
||||
notes: raw.notes,
|
||||
force: raw.force,
|
||||
min_supported,
|
||||
artifacts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一份可下载的产物。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Artifact {
|
||||
/// 目标平台。
|
||||
pub target: Target,
|
||||
/// 包形态。
|
||||
pub format: PackageFormat,
|
||||
/// 更新源内部的地址(怎么解释由源实现决定:相对路径 / `file://` / `https://`)。
|
||||
pub url: String,
|
||||
/// 字节数(下载后必须**恰好**这么多,短了就是截断)。
|
||||
pub size: u64,
|
||||
/// 期望摘要(已归一化为小写十六进制)。
|
||||
pub integrity: Integrity,
|
||||
}
|
||||
|
||||
impl Artifact {
|
||||
fn from_raw(version: &Version, raw: RawArtifact) -> Result<Self, UpdateError> {
|
||||
let target = Target::parse(&raw.target).ok_or_else(|| {
|
||||
UpdateError::invalid_manifest(format!(
|
||||
"版本 {version} 的产物 target `{}` 无法识别(形如 win-x64 / mac-arm64 / linux-x64)",
|
||||
raw.target
|
||||
))
|
||||
})?;
|
||||
let format = PackageFormat::parse(&raw.format).ok_or_else(|| {
|
||||
UpdateError::invalid_manifest(format!(
|
||||
"版本 {version} 的产物 format `{}` 无法识别",
|
||||
raw.format
|
||||
))
|
||||
})?;
|
||||
if raw.url.trim().is_empty() {
|
||||
return Err(UpdateError::invalid_manifest(format!(
|
||||
"版本 {version}({target})的产物 url 为空"
|
||||
)));
|
||||
}
|
||||
if raw.size == 0 {
|
||||
return Err(UpdateError::invalid_manifest(format!(
|
||||
"版本 {version}({target})的产物 size 为 0"
|
||||
)));
|
||||
}
|
||||
let integrity = match (raw.sha256, raw.sha1) {
|
||||
(Some(hex), _) => Integrity::sha256(hex),
|
||||
(None, Some(hex)) => Integrity::sha1(hex),
|
||||
(None, None) => {
|
||||
return Err(UpdateError::invalid_manifest(format!(
|
||||
"版本 {version}({target})的产物既没有 sha256 也没有 sha1"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if !integrity.is_well_formed() {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::InvalidManifest,
|
||||
format!(
|
||||
"版本 {version}({target})的 {} 摘要格式不合法(应为 {} 位十六进制)",
|
||||
integrity.algo.as_str(),
|
||||
integrity.algo.hex_len()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
target,
|
||||
format,
|
||||
url: raw.url,
|
||||
size: raw.size,
|
||||
integrity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawManifest {
|
||||
channel: String,
|
||||
#[serde(default)]
|
||||
releases: Vec<RawRelease>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawRelease {
|
||||
version: String,
|
||||
channel: Option<String>,
|
||||
released_at: Option<String>,
|
||||
notes: Option<String>,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
min_supported: Option<String>,
|
||||
#[serde(default)]
|
||||
artifacts: Vec<RawArtifact>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawArtifact {
|
||||
target: String,
|
||||
format: String,
|
||||
url: String,
|
||||
size: u64,
|
||||
sha256: Option<String>,
|
||||
sha1: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 自更新内核:版本比较、发布清单、渠道准入、下载校验、原子替换。
|
||||
//!
|
||||
//! 用户已定的主线是「安装包 + 自动更新通道」,但**更新源还没拍板**。所以这一层只做与源无关
|
||||
//! 的部分,源本身是插拔的 [`UpdateSource`]:core 里没有任何 CDN 常量、没有任何厂商名,
|
||||
//! 接哪家由上层注入(本地目录源 [`DirSource`] 是内置的对照/测试实现)。
|
||||
//!
|
||||
//! 设计输入:`DevDocs/analyse/07-settings-packaging-update-p2.md` §4(HMCL 自更新)与 §6.2
|
||||
//! (多平台清单扩展)。我们抄骨架、补两处 HMCL 明确放弃的东西:**回滚**与**按平台分发**。
|
||||
//!
|
||||
//! # 一条完整的更新链
|
||||
//!
|
||||
//! ```text
|
||||
//! UpdateChecker::check(current, channel) 清单 + 版本 + 渠道 + 目标平台 → 结论
|
||||
//! │ Available(candidate)
|
||||
//! ├─ UpdateChecker::prepare(candidate, staging) 取包 → 长度/摘要双检
|
||||
//! │ │ StagedPackage(校验通过,失败已自动清理)
|
||||
//! │ └─ install::install_staged(...) 改名旧版 → 搬入新版(失败回滚)
|
||||
//! └─ UpToDate / MissingArtifact
|
||||
//! ```
|
||||
//!
|
||||
//! # 分层
|
||||
//!
|
||||
//! ```text
|
||||
//! version 语义化版本解析与排序(alpha/beta/rc、build metadata) channel 渠道阶梯与准入
|
||||
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态
|
||||
//! source UpdateSource trait + 本地目录源 verify 暂存 + 长度/摘要校验
|
||||
//! checker 选择哪一份发布、急不急 install 原子替换 + 回滚
|
||||
//! error 分类与文案
|
||||
//! ```
|
||||
//!
|
||||
//! # 刻意不做的事
|
||||
//!
|
||||
//! * **不碰 UI**:本模块只产出可序列化的判定结果与错误分类,弹窗/进度/文案排版在壳层。
|
||||
//! 入口在 `crates/presenter`/`crates/app-iced` 接,`crates/core` 保持零 UI 依赖。
|
||||
//! * **不自己替换正在运行的 exe**:那需要「退出自身 → 另一个进程搬运」(HMCL 的
|
||||
//! `--apply-to`),属启动器外壳的编排;本模块只保证搬运本身原子且可回滚。
|
||||
//! * **不装安装器形态**(msi/dmg/deb/rpm):交给系统包管理器。
|
||||
//! * **不做差分/增量**:清单里没有 patch 字段,宁可每次全量,也别先上一套没验过的差分协议。
|
||||
|
||||
pub mod channel;
|
||||
pub mod checker;
|
||||
pub mod error;
|
||||
pub mod install;
|
||||
pub mod manifest;
|
||||
pub mod source;
|
||||
pub mod target;
|
||||
pub mod verify;
|
||||
pub mod version;
|
||||
|
||||
pub use crate::download::{HashAlgo, Integrity};
|
||||
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::install::{
|
||||
backup_path, install_staged, InstallFailure, InstallReceipt, ReplacePolicy,
|
||||
};
|
||||
pub use self::manifest::{Artifact, Release, ReleaseManifest};
|
||||
pub use self::source::{DirSource, UpdateSource};
|
||||
pub use self::target::{Arch, PackageFormat, Target};
|
||||
pub use self::verify::{stage, verify_file, StagedPackage};
|
||||
pub use self::version::{Stage, Version, VersionError};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。
|
||||
//!
|
||||
//! 更新源(CDN / 对象存储 / GitHub Release / 内网文件服务器)**还没拍板**,所以这里
|
||||
//! 刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层
|
||||
//! 注入。将来接 HTTP 源时,实现里用 `tokio`/`reqwest` 或
|
||||
//! [`crate::download::blocking::block_on`] 都行,trait 形状不用动。
|
||||
//!
|
||||
//! 内置的实现只有一个:[`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发、
|
||||
//! 以及给将来的 HTTP 实现当对照物。
|
||||
//!
|
||||
//! # 本地源的目录约定
|
||||
//!
|
||||
//! ```text
|
||||
//! <root>/stable.json 渠道清单(文件名 = 渠道名 + .json)
|
||||
//! <root>/beta.json
|
||||
//! <root>/<产物文件> 清单里 url 写相对路径时,相对 root
|
||||
//! ```
|
||||
//!
|
||||
//! 清单里的 `url` 支持两种写法:`file://` 绝对地址,或相对 `<root>` 的路径。
|
||||
//! 出现 `http://` / `https://` 一律 [`UpdateErrorKind::UnsupportedSource`]——那是别的
|
||||
//! 实现的活,本地源不该假装能取远端字节。
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::channel::Channel;
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::manifest::{Artifact, ReleaseManifest};
|
||||
|
||||
/// 更新源:清单与包体的来源。
|
||||
///
|
||||
/// 实现必须是 `Send + Sync`(下载与替换可能在不同线程上跑),且**不持有运行时**:
|
||||
/// 同步签名是为了让 CLI 与 GUI 都能直接调用,异步实现自己在方法里桥接。
|
||||
pub trait UpdateSource: Send + Sync {
|
||||
/// 源标识(日志/事件里的 `source` 字段,如 `file:D:\dist`;健康度也按它归并)。
|
||||
fn source_id(&self) -> &str;
|
||||
|
||||
/// 取某渠道的清单;`Ok(None)` = 该渠道还没发布过(不是错误)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读不到文件、JSON 坏、清单自报的渠道与 `channel` 不符都返回 [`UpdateError`]。
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError>;
|
||||
|
||||
/// 把产物取到 `dest`,返回写入的字节数。
|
||||
///
|
||||
/// 实现只管「把字节放到 dest」;长度与摘要校验由 [`crate::update::verify`] 统一做,
|
||||
/// 别在实现里各写一套。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 源里没有这个文件、读失败、写 `dest` 失败都返回 [`UpdateError`]。
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError>;
|
||||
}
|
||||
|
||||
/// 本地目录源(也接受 `file://` 根地址)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DirSource {
|
||||
root: PathBuf,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl DirSource {
|
||||
/// 用本地目录构造。
|
||||
#[must_use]
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
let root = root.into();
|
||||
let id = format!("file:{}", root.display());
|
||||
Self { root, id }
|
||||
}
|
||||
|
||||
/// 用 `file://` 地址构造(`file:///D:/dist`、`file:///srv/ull`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 不是 `file://` 开头的地址返回 [`UpdateErrorKind::UnsupportedSource`]。
|
||||
pub fn from_url(url: &str) -> Result<Self, UpdateError> {
|
||||
let rest = url
|
||||
.strip_prefix("file://")
|
||||
.ok_or_else(|| unsupported_source(url))?;
|
||||
Ok(Self::new(file_url_to_path(rest)))
|
||||
}
|
||||
|
||||
/// 根目录。
|
||||
#[must_use]
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// 某渠道的清单文件路径。
|
||||
#[must_use]
|
||||
pub fn manifest_path(&self, channel: Channel) -> PathBuf {
|
||||
self.root.join(format!("{}.json", channel.as_str()))
|
||||
}
|
||||
|
||||
/// 把清单里的 `url` 解析成源侧本地路径。
|
||||
fn resolve(&self, url: &str) -> Result<PathBuf, UpdateError> {
|
||||
if let Some(rest) = url.strip_prefix("file://") {
|
||||
return Ok(file_url_to_path(rest));
|
||||
}
|
||||
if url.contains("://") {
|
||||
return Err(unsupported_source(url));
|
||||
}
|
||||
Ok(self.root.join(url))
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_source(url: &str) -> UpdateError {
|
||||
let hint = if url.starts_with("http://") || url.starts_with("https://") {
|
||||
"本地目录源不取远端字节,请换用实现远端源的 UpdateSource"
|
||||
} else {
|
||||
"只支持本地相对路径与 file:// 地址"
|
||||
};
|
||||
UpdateError::new(
|
||||
UpdateErrorKind::UnsupportedSource,
|
||||
format!("更新源不支持地址 `{url}`:{hint}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn file_url_to_path(rest: &str) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
// file:///D:/dist → 去掉盘符前的斜杠;Windows 上没有「以 / 开头的当前盘路径」这种写法。
|
||||
PathBuf::from(rest.trim_start_matches('/'))
|
||||
} else {
|
||||
PathBuf::from(rest)
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateSource for DirSource {
|
||||
fn source_id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn fetch_manifest(&self, channel: Channel) -> Result<Option<ReleaseManifest>, UpdateError> {
|
||||
let path = self.manifest_path(channel);
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(source) => return Err(UpdateError::io_at(&path, &source)),
|
||||
};
|
||||
let manifest = ReleaseManifest::from_json(&text).map_err(|error| error.with_path(&path))?;
|
||||
if manifest.channel != channel {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::ChannelMismatch,
|
||||
format!(
|
||||
"请求的是 {} 渠道,清单 {} 自报 {}",
|
||||
channel,
|
||||
path.display(),
|
||||
manifest.channel
|
||||
),
|
||||
)
|
||||
.with_path(&path));
|
||||
}
|
||||
Ok(Some(manifest))
|
||||
}
|
||||
|
||||
fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result<u64, UpdateError> {
|
||||
let from = self.resolve(&artifact.url)?;
|
||||
if let Some(parent) = dest.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent).map_err(|error| UpdateError::io_at(parent, &error))?;
|
||||
}
|
||||
}
|
||||
match fs::copy(&from, dest) {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Err(UpdateError::new(
|
||||
UpdateErrorKind::SourceMissing,
|
||||
format!("源里没有产物 `{}`", artifact.url),
|
||||
)
|
||||
.with_path(&from)),
|
||||
Err(source) => Err(UpdateError::io_at(&from, &source)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! 产物目标平台与包格式:清单里 `target` / `format` 两个字段的取值。
|
||||
//!
|
||||
//! 系统家族直接复用 [`OsFamily`](`core::platform` 的口径),这里不另立一套
|
||||
//! Windows/macOS/Linux 枚举——否则同一件事会有两个真相源。
|
||||
//!
|
||||
//! `target` 的清单写法是 `系统-架构`,例如 `win-x64` / `mac-arm64` / `linux-arm64`。
|
||||
//! 解析容忍常见别名(`windows` / `macos` / `osx`、`x86_64` / `amd64`、`aarch64`),
|
||||
//! 因为清单是人(或 CI 脚本)拼出来的,别让 `win-amd64` 这种写法静默变成「没有可用产物」。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::platform::os_info::OsFamily;
|
||||
|
||||
/// 产物架构。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Arch {
|
||||
/// x86_64 / amd64。
|
||||
X64,
|
||||
/// aarch64 / arm64。
|
||||
Arm64,
|
||||
/// i686 / x86 32 位。
|
||||
X86,
|
||||
/// 编译目标认不出的架构(对照组永远匹配不上,于是走「本平台没有产物」那条路)。
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Arch {
|
||||
/// 清单写法。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::X64 => "x64",
|
||||
Self::Arm64 => "arm64",
|
||||
Self::X86 => "x86",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前编译目标的架构。
|
||||
#[must_use]
|
||||
pub const fn current() -> Self {
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
Self::X64
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
Self::Arm64
|
||||
} else if cfg!(target_arch = "x86") {
|
||||
Self::X86
|
||||
} else {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析清单写法(容忍 `x86_64` / `amd64` / `aarch64` / `i686` 等别名)。
|
||||
#[must_use]
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
match text.trim().to_ascii_lowercase().as_str() {
|
||||
"x64" | "x86_64" | "x86-64" | "amd64" => Some(Self::X64),
|
||||
"arm64" | "aarch64" | "arm64e" => Some(Self::Arm64),
|
||||
"x86" | "i686" | "i386" | "x86_32" => Some(Self::X86),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 产物目标平台(系统 + 架构)。
|
||||
///
|
||||
/// 没派生 `Hash`/`Ord`:`OsFamily` 尚未派生这两个 trait(`core::platform` 不在本 mission
|
||||
/// 的改动范围内),需要当 Map 键时再回头补,别在这里另造一套系统枚举绕过去。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Target {
|
||||
/// 系统家族。
|
||||
pub os: OsFamily,
|
||||
/// 架构。
|
||||
pub arch: Arch,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub const fn new(os: OsFamily, arch: Arch) -> Self {
|
||||
Self { os, arch }
|
||||
}
|
||||
|
||||
/// 当前编译目标。
|
||||
#[must_use]
|
||||
pub fn current() -> Self {
|
||||
Self::new(OsFamily::current(), Arch::current())
|
||||
}
|
||||
|
||||
/// 解析清单写法(`系统-架构`)。
|
||||
#[must_use]
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
let (os, arch) = text.trim().split_once('-')?;
|
||||
Some(Self::new(parse_os(os)?, Arch::parse(arch)?))
|
||||
}
|
||||
|
||||
/// 清单写法。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match (self.os, self.arch) {
|
||||
(OsFamily::Windows, Arch::X64) => "win-x64",
|
||||
(OsFamily::Windows, Arch::Arm64) => "win-arm64",
|
||||
(OsFamily::Windows, Arch::X86) => "win-x86",
|
||||
(OsFamily::Windows, Arch::Unknown) => "win-unknown",
|
||||
(OsFamily::MacOs, Arch::X64) => "mac-x64",
|
||||
(OsFamily::MacOs, Arch::Arm64) => "mac-arm64",
|
||||
(OsFamily::MacOs, Arch::X86) => "mac-x86",
|
||||
(OsFamily::MacOs, Arch::Unknown) => "mac-unknown",
|
||||
(OsFamily::Linux, Arch::X64) => "linux-x64",
|
||||
(OsFamily::Linux, Arch::Arm64) => "linux-arm64",
|
||||
(OsFamily::Linux, Arch::X86) => "linux-x86",
|
||||
(OsFamily::Linux, Arch::Unknown) => "linux-unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_os(text: &str) -> Option<OsFamily> {
|
||||
match text.trim().to_ascii_lowercase().as_str() {
|
||||
"win" | "windows" => Some(OsFamily::Windows),
|
||||
"mac" | "macos" | "osx" | "darwin" => Some(OsFamily::MacOs),
|
||||
"linux" => Some(OsFamily::Linux),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Target {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装包形态。
|
||||
///
|
||||
/// 只区分「安装器装出来的」与「解压即用的」两大类,具体怎么装由打包层决定:
|
||||
/// 安装器装的实例(msi/dmg/deb/rpm)本模块**不主张**原地覆盖(见 `install` 模块文档),
|
||||
/// 便携形态(portable-zip / appimage / portable-exe)才走自替换。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum PackageFormat {
|
||||
/// Windows 安装器。
|
||||
Msi,
|
||||
/// macOS 磁盘映像。
|
||||
Dmg,
|
||||
/// Linux AppImage。
|
||||
AppImage,
|
||||
/// Debian 包。
|
||||
Deb,
|
||||
/// RPM 包。
|
||||
Rpm,
|
||||
/// 便携 zip(解压即用)。
|
||||
PortableZip,
|
||||
/// 便携单文件 exe。
|
||||
PortableExe,
|
||||
}
|
||||
|
||||
impl PackageFormat {
|
||||
/// 清单写法。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Msi => "msi",
|
||||
Self::Dmg => "dmg",
|
||||
Self::AppImage => "appimage",
|
||||
Self::Deb => "deb",
|
||||
Self::Rpm => "rpm",
|
||||
Self::PortableZip => "portable-zip",
|
||||
Self::PortableExe => "portable-exe",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析清单写法。
|
||||
#[must_use]
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
match text.trim().to_ascii_lowercase().as_str() {
|
||||
"msi" => Some(Self::Msi),
|
||||
"dmg" => Some(Self::Dmg),
|
||||
"appimage" => Some(Self::AppImage),
|
||||
"deb" => Some(Self::Deb),
|
||||
"rpm" => Some(Self::Rpm),
|
||||
"portable-zip" | "zip" => Some(Self::PortableZip),
|
||||
"portable-exe" | "exe" => Some(Self::PortableExe),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否需要安装器代劳(这些形态不该被本模块原地覆盖)。
|
||||
#[must_use]
|
||||
pub const fn needs_installer(self) -> bool {
|
||||
matches!(self, Self::Msi | Self::Dmg | Self::Deb | Self::Rpm)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PackageFormat {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 渠道阶梯与准入:清单渠道比大小,预发布阶段按表收。
|
||||
|
||||
use crate::update::{Channel, Stage};
|
||||
|
||||
#[test]
|
||||
fn parses_names_loosely() {
|
||||
assert_eq!(Channel::parse("stable").expect("stable"), Channel::Stable);
|
||||
assert_eq!(Channel::parse(" STABLE ").expect("大写"), Channel::Stable);
|
||||
assert_eq!(Channel::parse("Beta").expect("beta"), Channel::Beta);
|
||||
assert_eq!(
|
||||
Channel::parse("nightly").expect("nightly"),
|
||||
Channel::Nightly
|
||||
);
|
||||
let error = Channel::parse("canary").expect_err("canary 不认");
|
||||
assert!(error.to_string().contains("canary"));
|
||||
assert_eq!(
|
||||
Channel::ALL.map(Channel::as_str),
|
||||
["stable", "beta", "alpha", "nightly"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_admits_only_more_stable_releases() {
|
||||
assert!(Channel::Stable.admits(Channel::Stable));
|
||||
assert!(!Channel::Stable.admits(Channel::Beta));
|
||||
assert!(Channel::Beta.admits(Channel::Stable));
|
||||
assert!(Channel::Beta.admits(Channel::Beta));
|
||||
assert!(!Channel::Beta.admits(Channel::Alpha));
|
||||
assert!(!Channel::Alpha.admits(Channel::Nightly));
|
||||
assert!(Channel::Alpha.admits(Channel::Beta));
|
||||
assert!(Channel::Nightly.admits(Channel::Alpha));
|
||||
assert!(Channel::Nightly.admits(Channel::Stable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_stages_are_gated_by_channel() {
|
||||
// 正式版对所有渠道都成立
|
||||
for channel in Channel::ALL {
|
||||
assert!(channel.admits_stage(Stage::Stable), "{channel}");
|
||||
}
|
||||
assert!(!Channel::Stable.admits_stage(Stage::ReleaseCandidate));
|
||||
assert!(Channel::Beta.admits_stage(Stage::ReleaseCandidate));
|
||||
assert!(!Channel::Beta.admits_stage(Stage::Beta));
|
||||
assert!(Channel::Alpha.admits_stage(Stage::Beta));
|
||||
assert!(!Channel::Alpha.admits_stage(Stage::Alpha));
|
||||
assert!(!Channel::Alpha.admits_stage(Stage::Unknown));
|
||||
assert!(Channel::Nightly.admits_stage(Stage::Alpha));
|
||||
assert!(Channel::Nightly.admits_stage(Stage::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_from_str_agree() {
|
||||
for channel in Channel::ALL {
|
||||
assert_eq!(channel.as_str().parse::<Channel>().expect("回读"), channel);
|
||||
assert_eq!(channel.to_string(), channel.as_str());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! 检查更新:渠道准入、版本高低、目标平台漏发、强制更新。
|
||||
|
||||
use crate::platform::os_info::OsFamily;
|
||||
use crate::update::{
|
||||
Channel, CheckOutcome, DirSource, Target, UpdateChecker, UpdateErrorKind, UpdateSource,
|
||||
Urgency, Version,
|
||||
};
|
||||
|
||||
use super::support::{artifact, manifest, release, TempDir};
|
||||
|
||||
const PAYLOAD: &[u8] = b"checker payload";
|
||||
|
||||
fn target() -> Target {
|
||||
Target::new(OsFamily::Windows, crate::update::Arch::X64)
|
||||
}
|
||||
|
||||
/// 新建一个只放了这一份清单的本地源;返回的 `TempDir` 要活到用例结束。
|
||||
fn source_with(json: &str, channel: Channel) -> (TempDir, DirSource) {
|
||||
let dir = TempDir::new("checker");
|
||||
dir.write(&format!("{}.json", channel.as_str()), json.as_bytes());
|
||||
let source = DirSource::new(dir.path());
|
||||
(dir, source)
|
||||
}
|
||||
|
||||
fn check_outcome(json: &str, current: &str, channel: Channel) -> CheckOutcome {
|
||||
let (_dir, source) = source_with(json, channel);
|
||||
let version = Version::parse(current).expect("当前版本应可解析");
|
||||
UpdateChecker::new(source, target())
|
||||
.check(&version, channel)
|
||||
.expect("检查不该失败")
|
||||
}
|
||||
|
||||
fn stable_release(version: &str) -> String {
|
||||
manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
version,
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offers_the_numeric_newer_version() {
|
||||
// 0.9 → 0.10 的字典序陷阱在 checker 这一层也要挡住
|
||||
let outcome = check_outcome(&stable_release("0.10.0"), "0.9.0", Channel::Stable);
|
||||
let candidate = outcome.candidate().expect("应有更新");
|
||||
assert_eq!(candidate.release.version.to_string(), "0.10.0");
|
||||
assert_eq!(
|
||||
candidate.artifact.format,
|
||||
crate::update::PackageFormat::PortableZip
|
||||
);
|
||||
assert_eq!(candidate.urgency, Urgency::Optional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_or_older_release_is_up_to_date() {
|
||||
let outcome = check_outcome(&stable_release("0.2.1"), "0.2.1", Channel::Stable);
|
||||
match outcome {
|
||||
CheckOutcome::UpToDate { latest, .. } => {
|
||||
assert_eq!(latest.map(|v| v.to_string()).as_deref(), Some("0.2.1"));
|
||||
}
|
||||
other => panic!("同等版本应已是最新,实际 {other:?}"),
|
||||
}
|
||||
let outcome = check_outcome(&stable_release("0.2.0"), "0.3.0", Channel::Stable);
|
||||
assert!(!outcome.is_available(), "不该提示降级");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_manifest_means_up_to_date() {
|
||||
let tmp = TempDir::new("checker-empty");
|
||||
let source = DirSource::new(tmp.path());
|
||||
let current = Version::parse("0.2.1").expect("版本");
|
||||
let outcome = UpdateChecker::new(source, target())
|
||||
.check(¤t, Channel::Stable)
|
||||
.expect("没清单不是错误");
|
||||
match outcome {
|
||||
CheckOutcome::UpToDate { latest, .. } => assert_eq!(latest, None),
|
||||
other => panic!("应为已是最新,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_is_gated_by_channel_stage() {
|
||||
let json = manifest(
|
||||
"beta",
|
||||
&[release(
|
||||
"1.0.0-rc.1",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
// beta 渠道收 rc
|
||||
let outcome = check_outcome(&json, "0.9.0", Channel::Beta);
|
||||
assert_eq!(
|
||||
outcome.candidate().map(|c| c.release.version.to_string()),
|
||||
Some("1.0.0-rc.1".to_string())
|
||||
);
|
||||
// alpha 发布在 beta 渠道里不收(阶段阶梯)
|
||||
let alpha_json = manifest(
|
||||
"beta",
|
||||
&[release(
|
||||
"1.1.0-alpha.1",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
let outcome = check_outcome(&alpha_json, "0.9.0", Channel::Beta);
|
||||
assert!(!outcome.is_available(), "beta 渠道不该收 alpha 预发布");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_channel_never_takes_a_prerelease_even_if_the_manifest_claims_stable() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"1.0.0-beta.2",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
let outcome = check_outcome(&json, "0.9.0", Channel::Stable);
|
||||
assert!(
|
||||
!outcome.is_available(),
|
||||
"清单可以写错,版本号不会:正式版渠道不许收预发布"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_update_comes_from_force_or_min_supported() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
r#","force":true"#,
|
||||
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
assert_eq!(
|
||||
check_outcome(&json, "0.2.0", Channel::Stable)
|
||||
.candidate()
|
||||
.map(|candidate| candidate.urgency),
|
||||
Some(Urgency::Forced)
|
||||
);
|
||||
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
r#","min_supported":"0.2.5""#,
|
||||
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
assert_eq!(
|
||||
check_outcome(&json, "0.2.0", Channel::Stable)
|
||||
.candidate()
|
||||
.map(|candidate| candidate.urgency),
|
||||
Some(Urgency::Forced),
|
||||
"低于最低支持版本 = 强制"
|
||||
);
|
||||
assert_eq!(
|
||||
check_outcome(&json, "0.2.5", Channel::Stable)
|
||||
.candidate()
|
||||
.map(|candidate| candidate.urgency),
|
||||
Some(Urgency::Optional),
|
||||
"刚好等于最低支持版本不算强制"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_target_artifact_is_reported_not_hidden() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("linux-x64", "appimage", "a.AppImage", PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
let outcome = check_outcome(&json, "0.2.0", Channel::Stable);
|
||||
match outcome {
|
||||
CheckOutcome::MissingArtifact { release } => {
|
||||
assert_eq!(release.version.to_string(), "0.3.0");
|
||||
}
|
||||
other => panic!("应为「有新版但没本平台产物」,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_newest_release_that_does_have_the_target() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[
|
||||
release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
|
||||
),
|
||||
release(
|
||||
"0.4.0",
|
||||
"",
|
||||
&[artifact("linux-x64", "appimage", "b.AppImage", PAYLOAD)],
|
||||
),
|
||||
],
|
||||
);
|
||||
let outcome = check_outcome(&json, "0.2.0", Channel::Stable);
|
||||
assert_eq!(
|
||||
outcome.candidate().map(|c| c.release.version.to_string()),
|
||||
Some("0.3.0".to_string()),
|
||||
"0.4.0 没 win-x64 产物,应退回到 0.3.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_mismatch_in_the_source_is_an_error() {
|
||||
let tmp = TempDir::new("checker-mismatch");
|
||||
tmp.write("stable.json", manifest("beta", &[]).as_bytes());
|
||||
let source = crate::update::DirSource::new(tmp.path());
|
||||
let current = crate::update::Version::parse("0.2.1").expect("版本");
|
||||
let error = UpdateChecker::new(source, target())
|
||||
.check(¤t, Channel::Stable)
|
||||
.expect_err("清单渠道不符应报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_stages_the_selected_artifact() {
|
||||
let tmp = TempDir::new("checker-prepare");
|
||||
tmp.write("a.zip", PAYLOAD);
|
||||
tmp.write("stable.json", stable_release("0.3.0").as_bytes());
|
||||
let source = crate::update::DirSource::new(tmp.path());
|
||||
let checker = UpdateChecker::new(source, target());
|
||||
let current = crate::update::Version::parse("0.2.0").expect("版本");
|
||||
let outcome = checker
|
||||
.check(¤t, Channel::Stable)
|
||||
.expect("检查不该失败");
|
||||
let candidate = outcome.candidate().expect("应有更新");
|
||||
let staged = checker
|
||||
.prepare(candidate, &tmp.join("staging"))
|
||||
.expect("取包应成功");
|
||||
assert_eq!(staged.size(), PAYLOAD.len() as u64);
|
||||
assert_eq!(checker.target(), target());
|
||||
assert!(checker.source().source_id().starts_with("file:"));
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 替换与回滚:成功后备份还在、失败后旧版本仍在原位、目标目录写不进去要报错。
|
||||
//!
|
||||
//! Windows 的「文件被占用」用例是**真造出来**的:用 `share_mode(FILE_SHARE_READ)` 打开目标
|
||||
//! 文件(不带 `FILE_SHARE_DELETE`,与 Windows 加载器打开运行中映像的口径一致),让 `rename`
|
||||
//! 撞共享冲突。它不是「运行中的 exe」(那需要真跑一个子进程,属真机验收),这一点在报告里写明。
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::update::manifest::ReleaseManifest;
|
||||
use crate::update::{
|
||||
install_staged, stage, DirSource, InstallFailure, ReplacePolicy, StagedPackage,
|
||||
};
|
||||
|
||||
use super::support::{artifact, manifest, release, TempDir};
|
||||
|
||||
const UPDATE: &[u8] = b"new build bytes";
|
||||
const OLD: &[u8] = b"old build bytes";
|
||||
|
||||
fn policy() -> ReplacePolicy {
|
||||
ReplacePolicy::new(2, std::time::Duration::from_millis(1))
|
||||
}
|
||||
|
||||
/// 发布一个包体、跑完取包+校验,拿到可直接安装的 `StagedPackage`。
|
||||
fn staged(tmp: &TempDir) -> StagedPackage {
|
||||
tmp.write("ull-test.zip", UPDATE);
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "ull-test.zip", UPDATE)],
|
||||
)],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
|
||||
stage(
|
||||
&DirSource::new(tmp.path()),
|
||||
&parsed.releases[0].artifacts[0],
|
||||
&tmp.join("staging"),
|
||||
)
|
||||
.expect("取包与校验应通过")
|
||||
}
|
||||
|
||||
fn names_in(dir: &Path) -> Vec<String> {
|
||||
match fs::read_dir(dir) {
|
||||
Ok(entries) => {
|
||||
let mut names = entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_over_an_existing_file_and_keeps_the_backup() {
|
||||
let tmp = TempDir::new("install-ok");
|
||||
let dest = tmp.write("app/ull.exe", OLD);
|
||||
let staged = staged(&tmp);
|
||||
let receipt = install_staged(&staged, &dest, policy()).expect("替换应成功");
|
||||
|
||||
assert_eq!(fs::read(&dest).expect("目标应存在"), UPDATE);
|
||||
assert_eq!(receipt.bytes, UPDATE.len() as u64);
|
||||
assert_eq!(receipt.dest, dest);
|
||||
let backup = receipt.backup.clone().expect("旧版本应被留在备份路径");
|
||||
assert_eq!(fs::read(&backup).expect("备份应存在"), OLD);
|
||||
assert_eq!(
|
||||
backup.parent(),
|
||||
dest.parent(),
|
||||
"备份必须与目标同目录,跨卷 rename 会失去原子性"
|
||||
);
|
||||
|
||||
receipt.remove_backup().expect("删备份");
|
||||
assert!(!backup.exists(), "备份应被删掉");
|
||||
assert_eq!(names_in(dest.parent().expect("父目录")), ["ull.exe"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installing_into_a_fresh_path_has_no_backup() {
|
||||
let tmp = TempDir::new("install-fresh");
|
||||
let staged = staged(&tmp);
|
||||
let dest = tmp.join("dist/ull.exe");
|
||||
let receipt = install_staged(&staged, &dest, policy()).expect("新装应成功");
|
||||
assert_eq!(receipt.backup, None);
|
||||
assert_eq!(fs::read(&dest).expect("目标应存在"), UPDATE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_move_rolls_the_old_version_back() {
|
||||
let tmp = TempDir::new("install-rollback");
|
||||
let dest = tmp.write("app/ull.exe", OLD);
|
||||
let staged = staged(&tmp);
|
||||
// 让第二步(暂存包 → 目标)注定失败:把暂存文件删掉,等价于"搬到一半源没了"。
|
||||
fs::remove_file(staged.path()).expect("删掉暂存文件");
|
||||
|
||||
let failure: InstallFailure =
|
||||
install_staged(&staged, &dest, policy()).expect_err("搬不进去必须失败");
|
||||
assert!(failure.rolled_back, "旧版本应回到原位:{failure}");
|
||||
assert!(!failure.needs_manual_repair());
|
||||
assert_eq!(failure.backup_left, None);
|
||||
assert_eq!(fs::read(&dest).expect("旧版本应仍在"), OLD);
|
||||
assert_eq!(
|
||||
names_in(dest.parent().expect("父目录")),
|
||||
["ull.exe"],
|
||||
"回滚后不该留下备份文件"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_rollback_restores_the_previous_build() {
|
||||
let tmp = TempDir::new("install-manual-rollback");
|
||||
let dest = tmp.write("app/ull.exe", OLD);
|
||||
let staged = staged(&tmp);
|
||||
let mut receipt = install_staged(&staged, &dest, policy()).expect("替换应成功");
|
||||
assert_eq!(fs::read(&dest).expect("新版本"), UPDATE);
|
||||
let backup = receipt.backup.clone().expect("旧版本备份");
|
||||
|
||||
receipt.rollback(policy()).expect("回滚应成功");
|
||||
assert_eq!(fs::read(&dest).expect("旧版本应回来"), OLD);
|
||||
assert!(!backup.exists(), "回滚会把备份搬回去,不留副本");
|
||||
assert_eq!(receipt.backup, None, "回滚成功后不该再指着不存在的备份");
|
||||
assert!(receipt.remove_backup().is_ok(), "没有备份时删备份是空操作");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritable_target_directory_fails_without_touching_anything() {
|
||||
let tmp = TempDir::new("install-unwritable");
|
||||
let staged = staged(&tmp);
|
||||
// 父路径是个普通文件:建目录必然失败,等价于"目标目录不可写"
|
||||
// (比 chmod 555 更可移植:CI 里常以 root 跑,chmod 对 root 无效)。
|
||||
let blocked = tmp.write("blocked", b"not a directory");
|
||||
let dest = blocked.join("app/ull.exe");
|
||||
|
||||
let failure = install_staged(&staged, &dest, policy()).expect_err("写不进目标目录必须失败");
|
||||
assert!(failure.rolled_back, "没写任何东西,旧状态未变:{failure}");
|
||||
assert_eq!(
|
||||
fs::read(&blocked).expect("占位文件应原样"),
|
||||
b"not a directory"
|
||||
);
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_locked_target_fails_and_leaves_the_old_file_intact() {
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
|
||||
/// `FILE_SHARE_READ`:只允许别人读,不允许改名/删除。
|
||||
const FILE_SHARE_READ: u32 = 0x1;
|
||||
|
||||
let tmp = TempDir::new("install-locked");
|
||||
let dest = tmp.write("app/ull.exe", OLD);
|
||||
let staged = staged(&tmp);
|
||||
// 刻意**不**共享删除:`std::fs::File::open` 默认带 FILE_SHARE_DELETE(改名会被放行),
|
||||
// 而 Windows 加载器打开正在运行的映像时不带它——这才是我们要复现的占用形态。
|
||||
let held = OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(FILE_SHARE_READ)
|
||||
.open(&dest)
|
||||
.expect("打开目标文件");
|
||||
let failure = install_staged(&staged, &dest, policy()).expect_err("占用中的文件改不了名");
|
||||
assert!(failure.rolled_back, "旧版本必须完好:{failure}");
|
||||
assert_eq!(fs::read(&dest).expect("旧版本应仍在"), OLD);
|
||||
drop(held);
|
||||
assert_eq!(
|
||||
names_in(dest.parent().expect("父目录")),
|
||||
["ull.exe"],
|
||||
"失败的尝试不该留下备份或暂存垃圾"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_path_stays_in_the_same_directory() {
|
||||
let dest = PathBuf::from("some/dir/ull.exe");
|
||||
let backup = crate::update::backup_path(&dest);
|
||||
assert_eq!(backup.parent(), dest.parent());
|
||||
let name = backup
|
||||
.file_name()
|
||||
.expect("备份名")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
assert!(name.starts_with(".ull.exe.ullold-"), "实际:{name}");
|
||||
assert_ne!(
|
||||
backup,
|
||||
crate::update::backup_path(&dest),
|
||||
"同一进程两次调用不能撞名"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! 清单解析与校验:字段拼错、重复版本、零长度、摘要格式、产物优先级。
|
||||
|
||||
use crate::download::HashAlgo;
|
||||
use crate::platform::os_info::OsFamily;
|
||||
use crate::update::manifest::ReleaseManifest;
|
||||
use crate::update::{Arch, Channel, PackageFormat, Target, UpdateErrorKind};
|
||||
|
||||
use super::support::{artifact, manifest, release, sha256};
|
||||
|
||||
const PAYLOAD: &[u8] = b"fake portable zip payload";
|
||||
|
||||
#[test]
|
||||
fn parses_a_full_manifest() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
r#","released_at":"2026-01-02T03:04:05Z","notes":"修掉启动崩溃","min_supported":"0.2.0""#,
|
||||
&[
|
||||
artifact("win-x64", "portable-zip", "ull-0.3.0-win-x64.zip", PAYLOAD),
|
||||
artifact(
|
||||
"linux-x64",
|
||||
"appimage",
|
||||
"ull-0.3.0-linux-x64.AppImage",
|
||||
PAYLOAD,
|
||||
),
|
||||
],
|
||||
)],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
|
||||
assert_eq!(parsed.channel, Channel::Stable);
|
||||
assert_eq!(parsed.releases.len(), 1);
|
||||
let first = &parsed.releases[0];
|
||||
assert_eq!(first.version.to_string(), "0.3.0");
|
||||
assert_eq!(first.channel, Channel::Stable, "渠道字段缺省时继承清单渠道");
|
||||
assert_eq!(first.notes.as_deref(), Some("修掉启动崩溃"));
|
||||
assert_eq!(
|
||||
first.min_supported.as_ref().map(ToString::to_string),
|
||||
Some("0.2.0".to_string())
|
||||
);
|
||||
assert!(!first.force);
|
||||
assert_eq!(first.artifacts.len(), 2);
|
||||
let windows = &first.artifacts[0];
|
||||
assert_eq!(windows.target, Target::new(OsFamily::Windows, Arch::X64));
|
||||
assert_eq!(windows.format, PackageFormat::PortableZip);
|
||||
assert_eq!(windows.integrity.algo, HashAlgo::Sha256);
|
||||
assert_eq!(windows.integrity.hex, sha256(PAYLOAD));
|
||||
assert_eq!(windows.size, PAYLOAD.len() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_can_override_the_manifest_channel() {
|
||||
let json = manifest("beta", &[release("0.3.0", r#","channel":"stable""#, &[])]);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
|
||||
assert_eq!(parsed.releases[0].channel, Channel::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_versions() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[
|
||||
release("0.3.0", "", &[]),
|
||||
release("0.3.0", r#","notes":"重复""#, &[]),
|
||||
],
|
||||
);
|
||||
let error = ReleaseManifest::from_json(&json).expect_err("重复版本应失败");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
assert!(error.to_string().contains("0.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_size_artifact() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[format!(
|
||||
r#"{{"target":"win-x64","format":"msi","url":"a.msi","size":0,"sha256":"{}"}}"#,
|
||||
sha256(b"")
|
||||
)],
|
||||
)],
|
||||
);
|
||||
let error = ReleaseManifest::from_json(&json).expect_err("size=0 应失败");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
assert!(error.to_string().contains("size"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_fields() {
|
||||
let json = r#"{"channel":"stable","relases":[]}"#;
|
||||
let error = ReleaseManifest::from_json(json).expect_err("拼错的字段名应失败");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_artifact_without_digest() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[r#"{"target":"win-x64","format":"msi","url":"a.msi","size":12}"#.to_string()],
|
||||
)],
|
||||
);
|
||||
let error = ReleaseManifest::from_json(&json).expect_err("缺摘要应失败");
|
||||
assert!(error.to_string().contains("sha256"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_digest() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[
|
||||
r#"{"target":"win-x64","format":"msi","url":"a.msi","size":12,"sha256":"abc"}"#
|
||||
.to_string(),
|
||||
],
|
||||
)],
|
||||
);
|
||||
let error = ReleaseManifest::from_json(&json).expect_err("摘要长度不对应失败");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_sha1_only_and_normalises_case() {
|
||||
// 64 位 hex 挂 sha1 名头是错长度的,先确认它真的被挡下
|
||||
let wrong_length = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[format!(
|
||||
r#"{{"target":"win-x64","format":"msi","url":"a.msi","size":12,"sha1":"{}"}}"#,
|
||||
sha256(PAYLOAD)
|
||||
)],
|
||||
)],
|
||||
);
|
||||
assert!(ReleaseManifest::from_json(&wrong_length).is_err());
|
||||
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[format!(
|
||||
r#"{{"target":"win-x64","format":"msi","url":"a.msi","size":12,"sha1":"{}"}}"#,
|
||||
"A".repeat(40)
|
||||
)],
|
||||
)],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("只有 sha1 也应可解析");
|
||||
let integrity = &parsed.releases[0].artifacts[0].integrity;
|
||||
assert_eq!(integrity.algo, HashAlgo::Sha1);
|
||||
assert_eq!(integrity.hex, "a".repeat(40), "摘要统一归一化为小写");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_wins_when_both_digests_are_present() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[format!(
|
||||
r#"{{"target":"win-x64","format":"msi","url":"a.msi","size":12,"sha1":"{}","sha256":"{}"}}"#,
|
||||
"a".repeat(40),
|
||||
sha256(PAYLOAD)
|
||||
)],
|
||||
)],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("两个摘要都在时应可解析");
|
||||
assert_eq!(
|
||||
parsed.releases[0].artifacts[0].integrity.algo,
|
||||
HashAlgo::Sha256
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_target_and_format() {
|
||||
let bad_target = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("solaris-x64", "msi", "a.msi", PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
let error = ReleaseManifest::from_json(&bad_target).expect_err("未知平台应失败");
|
||||
assert!(error.to_string().contains("solaris-x64"));
|
||||
|
||||
let bad_format = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("win-x64", "tar.gz", "a.tgz", PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
let error = ReleaseManifest::from_json(&bad_format).expect_err("未知格式应失败");
|
||||
assert!(error.to_string().contains("tar.gz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_order_is_the_priority() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[
|
||||
artifact("win-x64", "portable-zip", "a.zip", PAYLOAD),
|
||||
artifact("win-x64", "msi", "a.msi", PAYLOAD),
|
||||
],
|
||||
)],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
|
||||
let target = Target::new(OsFamily::Windows, Arch::X64);
|
||||
let artifact = parsed.releases[0]
|
||||
.artifact_for(target)
|
||||
.expect("应有 win-x64 产物");
|
||||
assert_eq!(artifact.format, PackageFormat::PortableZip, "先写先选");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_artifact_for_skips_releases_without_the_target() {
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[
|
||||
release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "a.zip", PAYLOAD)],
|
||||
),
|
||||
release(
|
||||
"0.4.0",
|
||||
"",
|
||||
&[artifact("linux-x64", "appimage", "b.AppImage", PAYLOAD)],
|
||||
),
|
||||
],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
|
||||
let (release, _) = parsed
|
||||
.newest_artifact_for(Target::new(OsFamily::Windows, Arch::X64))
|
||||
.expect("win-x64 只有 0.3.0 有货");
|
||||
assert_eq!(release.version.to_string(), "0.3.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_release_list_is_valid() {
|
||||
let parsed = ReleaseManifest::from_json(r#"{"channel":"nightly","releases":[]}"#)
|
||||
.expect("空清单是合法状态(还没发过版)");
|
||||
assert_eq!(parsed.channel, Channel::Nightly);
|
||||
assert!(parsed.releases.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! `update` 的单元测试。
|
||||
//!
|
||||
//! 覆盖面(与 mission 的验收条目一一对应):
|
||||
//!
|
||||
//! * 版本比较边界:`0.9` vs `0.10`、预发布 vs 正式、alpha/beta/rc、构建元数据不排序;
|
||||
//! * 清单校验:重复版本、零长度、缺摘要/摘要格式错、未知平台与格式;
|
||||
//! * 渠道准入:阶梯比较 + 预发布阶段表;
|
||||
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
|
||||
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
|
||||
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝。
|
||||
//!
|
||||
//! 全程离线:所有字节都在临时目录里造,没有一个用例发网络请求。
|
||||
|
||||
mod channel_cases;
|
||||
mod checker_cases;
|
||||
mod install_cases;
|
||||
mod manifest_cases;
|
||||
mod source_cases;
|
||||
mod support;
|
||||
mod target_cases;
|
||||
mod verify_cases;
|
||||
mod version_cases;
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 本地目录源:清单缺失不是错误、渠道对不上要报错、远端地址一律拒绝。
|
||||
|
||||
use std::fs;
|
||||
|
||||
use crate::update::manifest::ReleaseManifest;
|
||||
use crate::update::{Channel, DirSource, UpdateErrorKind, UpdateSource};
|
||||
|
||||
use super::support::{artifact, manifest, release, TempDir};
|
||||
|
||||
const PAYLOAD: &[u8] = b"source payload";
|
||||
|
||||
fn publish(tmp: &TempDir, channel: Channel, json: &str) {
|
||||
tmp.write(&format!("{}.json", channel.as_str()), json.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_channel_manifest_is_not_an_error() {
|
||||
let tmp = TempDir::new("source-missing");
|
||||
let source = DirSource::new(tmp.path());
|
||||
let manifest = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect("缺清单不该报错");
|
||||
assert!(manifest.is_none(), "还没发过版 = Ok(None)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_and_validates_the_channel_manifest() {
|
||||
let tmp = TempDir::new("source-ok");
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "ull-test.zip", PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
publish(&tmp, Channel::Stable, &json);
|
||||
let source = DirSource::new(tmp.path());
|
||||
let parsed = source
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect("应取到清单")
|
||||
.expect("清单存在");
|
||||
assert_eq!(parsed.channel, Channel::Stable);
|
||||
assert_eq!(parsed.releases.len(), 1);
|
||||
assert!(source.source_id().starts_with("file:"));
|
||||
assert_eq!(source.root(), tmp.path());
|
||||
assert_eq!(source.manifest_path(Channel::Beta), tmp.join("beta.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_mismatch_is_an_error() {
|
||||
let tmp = TempDir::new("source-mismatch");
|
||||
// 清单自称 beta,却躺在 stable.json 里
|
||||
publish(&tmp, Channel::Stable, &manifest("beta", &[]));
|
||||
let error = DirSource::new(tmp.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("渠道不符必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::ChannelMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_manifest_json_is_reported_with_path() {
|
||||
let tmp = TempDir::new("source-broken");
|
||||
publish(&tmp, Channel::Stable, "{ not json");
|
||||
let error = DirSource::new(tmp.path())
|
||||
.fetch_manifest(Channel::Stable)
|
||||
.expect_err("坏 JSON 必须报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::InvalidManifest);
|
||||
assert!(error.path.is_some(), "错误应带清单路径");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_artifact_from_a_relative_url() {
|
||||
let tmp = TempDir::new("source-copy");
|
||||
let file = tmp.write("dist/ull-test.zip", PAYLOAD);
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact(
|
||||
"win-x64",
|
||||
"portable-zip",
|
||||
"dist/ull-test.zip",
|
||||
PAYLOAD,
|
||||
)],
|
||||
)],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
|
||||
let dest = tmp.join("staging/copy.zip");
|
||||
let written = DirSource::new(tmp.path())
|
||||
.fetch_artifact(&parsed.releases[0].artifacts[0], &dest)
|
||||
.expect("应复制成功");
|
||||
assert_eq!(written, PAYLOAD.len() as u64);
|
||||
assert_eq!(fs::read(&dest).expect("目标应存在"), PAYLOAD);
|
||||
assert!(file.exists());
|
||||
assert!(dest.parent().is_some_and(|parent| parent.exists()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_file_url_for_the_artifact() {
|
||||
let tmp = TempDir::new("source-file-url");
|
||||
let file = tmp.write("dist/ull-test.zip", PAYLOAD);
|
||||
let url = format!("file://{}", file.display().to_string().replace('\\', "/"));
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", &url, PAYLOAD)],
|
||||
)],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
|
||||
let dest = tmp.join("staging/copy.zip");
|
||||
DirSource::new(tmp.join("a-different-root"))
|
||||
.fetch_artifact(&parsed.releases[0].artifacts[0], &dest)
|
||||
.expect("file:// 地址应可复制");
|
||||
assert_eq!(fs::read(&dest).expect("目标应存在"), PAYLOAD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_remote_urls() {
|
||||
let tmp = TempDir::new("source-remote");
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact(
|
||||
"win-x64",
|
||||
"portable-zip",
|
||||
"https://example.invalid/ull-test.zip",
|
||||
PAYLOAD,
|
||||
)],
|
||||
)],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
|
||||
let error = DirSource::new(tmp.path())
|
||||
.fetch_artifact(&parsed.releases[0].artifacts[0], &tmp.join("copy.zip"))
|
||||
.expect_err("本地源不该假装能取远端字节");
|
||||
assert_eq!(error.kind, UpdateErrorKind::UnsupportedSource);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_artifact_file_is_source_missing() {
|
||||
let tmp = TempDir::new("source-no-file");
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact(
|
||||
"win-x64",
|
||||
"portable-zip",
|
||||
"not-there.zip",
|
||||
PAYLOAD,
|
||||
)],
|
||||
)],
|
||||
);
|
||||
let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析");
|
||||
let error = DirSource::new(tmp.path())
|
||||
.fetch_artifact(&parsed.releases[0].artifacts[0], &tmp.join("copy.zip"))
|
||||
.expect_err("产物不存在应报错");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SourceMissing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_a_source_from_a_file_url() {
|
||||
let tmp = TempDir::new("source-from-url");
|
||||
let url = format!(
|
||||
"file://{}",
|
||||
tmp.path().display().to_string().replace('\\', "/")
|
||||
);
|
||||
let source = DirSource::from_url(&url).expect("file:// 应被接受");
|
||||
assert_eq!(source.root(), tmp.path(), "根目录应与给定目录一致");
|
||||
|
||||
let error = DirSource::from_url("https://example.invalid/dist").expect_err("https 不是本地源");
|
||||
assert_eq!(error.kind, UpdateErrorKind::UnsupportedSource);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! `update` 测试的公共夹具:临时目录、真实摘要、清单 JSON 片段。
|
||||
//!
|
||||
//! 清单片段里的摘要**按真实字节算**,不用占位值——「校验和不符」那条用例正是要证明
|
||||
//! 判据真的在跑,占位值会让它对所有输入都成立。
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::download::verify::digest_bytes;
|
||||
use crate::download::HashAlgo;
|
||||
|
||||
/// 一次性临时目录(`Drop` 时尽力删除;删不掉也留在系统 temp 下不碍事)。
|
||||
pub(crate) struct TempDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempDir {
|
||||
pub(crate) fn new(tag: &str) -> Self {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("ull-update-{}-{tag}-{unique}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("建临时目录");
|
||||
Self { path }
|
||||
}
|
||||
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub(crate) fn join(&self, name: &str) -> PathBuf {
|
||||
self.path.join(name)
|
||||
}
|
||||
|
||||
/// 写一个文件(父目录自动建),返回路径。
|
||||
pub(crate) fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
|
||||
let path = self.join(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("建父目录");
|
||||
}
|
||||
fs::write(&path, bytes).expect("写文件");
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 小写十六进制 SHA-256。
|
||||
pub(crate) fn sha256(bytes: &[u8]) -> String {
|
||||
digest_bytes(HashAlgo::Sha256, bytes)
|
||||
}
|
||||
|
||||
/// 清单里的一个产物对象(`sha256` 与 `size` 都按 `payload` 真实值算)。
|
||||
pub(crate) fn artifact(target: &str, format: &str, url: &str, payload: &[u8]) -> String {
|
||||
format!(
|
||||
r#"{{"target":"{target}","format":"{format}","url":"{url}","size":{},"sha256":"{}"}}"#,
|
||||
payload.len(),
|
||||
sha256(payload)
|
||||
)
|
||||
}
|
||||
|
||||
/// 清单里的一个发布对象;`extra` 追加其它字段(如 `,"force":true`),注意前导逗号。
|
||||
pub(crate) fn release(version: &str, extra: &str, artifacts: &[String]) -> String {
|
||||
format!(
|
||||
r#"{{"version":"{version}"{extra},"artifacts":[{}]}}"#,
|
||||
artifacts.join(",")
|
||||
)
|
||||
}
|
||||
|
||||
/// 一份渠道清单。
|
||||
pub(crate) fn manifest(channel: &str, releases: &[String]) -> String {
|
||||
format!(
|
||||
r#"{{"channel":"{channel}","releases":[{}]}}"#,
|
||||
releases.join(",")
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 目标平台与包形态的清单写法解析。
|
||||
|
||||
use crate::platform::os_info::OsFamily;
|
||||
use crate::update::{Arch, PackageFormat, Target};
|
||||
|
||||
#[test]
|
||||
fn parses_canonical_targets() {
|
||||
assert_eq!(
|
||||
Target::parse("win-x64"),
|
||||
Some(Target::new(OsFamily::Windows, Arch::X64))
|
||||
);
|
||||
assert_eq!(
|
||||
Target::parse("mac-arm64"),
|
||||
Some(Target::new(OsFamily::MacOs, Arch::Arm64))
|
||||
);
|
||||
assert_eq!(
|
||||
Target::parse("linux-x64"),
|
||||
Some(Target::new(OsFamily::Linux, Arch::X64))
|
||||
);
|
||||
assert_eq!(
|
||||
Target::parse("linux-arm64"),
|
||||
Some(Target::new(OsFamily::Linux, Arch::Arm64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_common_aliases() {
|
||||
assert_eq!(
|
||||
Target::parse("windows-amd64"),
|
||||
Some(Target::new(OsFamily::Windows, Arch::X64))
|
||||
);
|
||||
assert_eq!(
|
||||
Target::parse("osx-aarch64"),
|
||||
Some(Target::new(OsFamily::MacOs, Arch::Arm64))
|
||||
);
|
||||
assert_eq!(
|
||||
Target::parse("darwin-universal"),
|
||||
None,
|
||||
"认不出的架构必须返回 None,不能瞎猜"
|
||||
);
|
||||
assert_eq!(Target::parse("win"), None, "少了架构段");
|
||||
assert_eq!(Target::parse("solaris-x64"), None);
|
||||
assert_eq!(Target::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_target_renders_and_round_trips() {
|
||||
let current = Target::current();
|
||||
assert_eq!(Target::parse(current.as_str()), Some(current));
|
||||
assert_eq!(current.to_string(), current.as_str());
|
||||
assert_ne!(current.arch, Arch::Unknown, "编译目标架构应能识别");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_package_formats() {
|
||||
assert_eq!(PackageFormat::parse("msi"), Some(PackageFormat::Msi));
|
||||
assert_eq!(PackageFormat::parse("DMG"), Some(PackageFormat::Dmg));
|
||||
assert_eq!(
|
||||
PackageFormat::parse("appimage"),
|
||||
Some(PackageFormat::AppImage)
|
||||
);
|
||||
assert_eq!(
|
||||
PackageFormat::parse("portable-zip"),
|
||||
Some(PackageFormat::PortableZip)
|
||||
);
|
||||
assert_eq!(
|
||||
PackageFormat::parse("zip"),
|
||||
Some(PackageFormat::PortableZip)
|
||||
);
|
||||
assert_eq!(PackageFormat::parse("tar.gz"), None);
|
||||
assert_eq!(PackageFormat::Msi.to_string(), "msi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_formats_are_flagged() {
|
||||
for format in [
|
||||
PackageFormat::Msi,
|
||||
PackageFormat::Dmg,
|
||||
PackageFormat::Deb,
|
||||
PackageFormat::Rpm,
|
||||
] {
|
||||
assert!(format.needs_installer(), "{format}");
|
||||
}
|
||||
for format in [
|
||||
PackageFormat::PortableZip,
|
||||
PackageFormat::PortableExe,
|
||||
PackageFormat::AppImage,
|
||||
] {
|
||||
assert!(!format.needs_installer(), "{format}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! 校验层:摘要不符、包体截断都必须被挡下,且暂存文件不许留下。
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::update::manifest::ReleaseManifest;
|
||||
use crate::update::{stage, DirSource, UpdateErrorKind};
|
||||
|
||||
use super::support::{artifact, manifest, release, sha256, TempDir};
|
||||
|
||||
/// 暂存目录里的文件名(断言「失败后没留垃圾」用)。
|
||||
fn staging_files(dir: &Path) -> Vec<String> {
|
||||
match fs::read_dir(dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn artifact_of(json: &str) -> crate::update::Artifact {
|
||||
ReleaseManifest::from_json(json)
|
||||
.expect("清单应可解析")
|
||||
.releases[0]
|
||||
.artifacts[0]
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digest_mismatch_is_rejected_and_staged_file_removed() {
|
||||
let tmp = TempDir::new("verify-digest");
|
||||
let source = DirSource::new(tmp.path());
|
||||
// 清单声明的摘要按 A 算,实际发的是 B:长度一样,只有摘要能抓住。
|
||||
let declared = b"payload-aaaa";
|
||||
let delivered = b"payload-bbbb";
|
||||
assert_eq!(declared.len(), delivered.len());
|
||||
tmp.write("ull-test.zip", delivered);
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact(
|
||||
"win-x64",
|
||||
"portable-zip",
|
||||
"ull-test.zip",
|
||||
declared,
|
||||
)],
|
||||
)],
|
||||
);
|
||||
let artifact = artifact_of(&json);
|
||||
assert_eq!(artifact.integrity.hex, sha256(declared));
|
||||
|
||||
let staging = tmp.join("staging");
|
||||
let error = stage(&source, &artifact, &staging).expect_err("摘要不符必须拒绝");
|
||||
assert_eq!(error.kind, UpdateErrorKind::DigestMismatch);
|
||||
assert!(
|
||||
error
|
||||
.path
|
||||
.as_ref()
|
||||
.is_some_and(|path| path.starts_with(&staging)),
|
||||
"错误应指向暂存文件:{error}"
|
||||
);
|
||||
assert!(
|
||||
staging_files(&staging).is_empty(),
|
||||
"校验失败的暂存文件必须被删掉,实际:{:?}",
|
||||
staging_files(&staging)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_payload_is_rejected_as_size_mismatch() {
|
||||
let tmp = TempDir::new("verify-truncated");
|
||||
let source = DirSource::new(tmp.path());
|
||||
let full = b"a full portable zip payload";
|
||||
// 发布侧发一半就断了:长度先抓到,不用读摘要就能拒绝。
|
||||
tmp.write("ull-test.zip", &full[..8]);
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "ull-test.zip", full)],
|
||||
)],
|
||||
);
|
||||
let staging = tmp.join("staging");
|
||||
let error = stage(&source, &artifact_of(&json), &staging).expect_err("截断的包必须拒绝");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SizeMismatch);
|
||||
assert!(error.to_string().contains("截断"));
|
||||
assert!(staging_files(&staging).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_payload_is_rejected_too() {
|
||||
let tmp = TempDir::new("verify-oversized");
|
||||
let source = DirSource::new(tmp.path());
|
||||
tmp.write("ull-test.zip", b"payload and then some");
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact(
|
||||
"win-x64",
|
||||
"portable-zip",
|
||||
"ull-test.zip",
|
||||
b"payload",
|
||||
)],
|
||||
)],
|
||||
);
|
||||
let error = stage(&source, &artifact_of(&json), &tmp.join("staging"))
|
||||
.expect_err("多出来的字节同样不许装");
|
||||
assert_eq!(error.kind, UpdateErrorKind::SizeMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_payload_is_staged_and_cleaned_on_drop() {
|
||||
let tmp = TempDir::new("verify-ok");
|
||||
let source = DirSource::new(tmp.path());
|
||||
let payload = b"a real payload, short but real";
|
||||
tmp.write("ull-test.zip", payload);
|
||||
let json = manifest(
|
||||
"stable",
|
||||
&[release(
|
||||
"0.3.0",
|
||||
"",
|
||||
&[artifact("win-x64", "portable-zip", "ull-test.zip", payload)],
|
||||
)],
|
||||
);
|
||||
let staged = stage(&source, &artifact_of(&json), &tmp.join("staging")).expect("校验应通过");
|
||||
assert_eq!(staged.size(), payload.len() as u64);
|
||||
assert_eq!(fs::read(staged.path()).expect("暂存文件应存在"), payload);
|
||||
assert_eq!(staged.integrity().hex, sha256(payload));
|
||||
let path = staged.path().to_path_buf();
|
||||
drop(staged);
|
||||
assert!(!path.exists(), "StagedPackage 落地后应自行清理");
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 版本比较的边界用例:数值比而非字典序、预发布 vs 正式、构建元数据不参与排序。
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::update::{Stage, Version};
|
||||
|
||||
fn version(text: &str) -> Version {
|
||||
Version::parse(text).expect("测试里的版本号应可解析")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minor_numbers_compare_numerically() {
|
||||
// 字典序会把 "0.9.0" 判成比 "0.10.0" 新,这正是这条用例要钉死的行为。
|
||||
assert!(version("0.10.0") > version("0.9.0"));
|
||||
assert!(version("0.10.1") < version("0.11.0"));
|
||||
assert!(version("1.0.0") > version("0.999.999"));
|
||||
assert!(version("2.0.0") > version("1.99.99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_sorts_below_the_release() {
|
||||
assert!(version("1.0.0-rc.1") < version("1.0.0"));
|
||||
assert!(version("1.0.0-alpha") < version("1.0.0"));
|
||||
// 预发布阶段之间:alpha < beta < rc(ASCII 序,不是特例表)
|
||||
assert!(version("1.0.0-alpha") < version("1.0.0-beta"));
|
||||
assert!(version("1.0.0-beta.2") < version("1.0.0-rc.1"));
|
||||
// 主次修订号优先于预发布标识
|
||||
assert!(version("1.0.1-alpha.1") > version("1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_identifiers_follow_semver_rules() {
|
||||
assert!(version("1.0.0-alpha.1") < version("1.0.0-alpha.2"));
|
||||
assert!(version("1.0.0-alpha.2") < version("1.0.0-alpha.10"));
|
||||
assert!(version("1.0.0-alpha") < version("1.0.0-alpha.1"));
|
||||
// 纯数字段小于任何非数字段
|
||||
assert!(version("1.0.0-1") < version("1.0.0-alpha"));
|
||||
// 非数字段按 ASCII 比
|
||||
assert!(version("1.0.0-alpha-1") < version("1.0.0-alpha-x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_metadata_is_ignored_by_ordering_and_equality() {
|
||||
let with = version("1.2.3+build.7");
|
||||
let without = version("1.2.3");
|
||||
assert_eq!(with.cmp(&without), Ordering::Equal);
|
||||
assert_eq!(with, without);
|
||||
assert!(version("1.2.4+zzz") > version("1.2.3+aaa"));
|
||||
assert_eq!(with.to_string(), "1.2.3+build.7");
|
||||
assert_eq!(with.build_metadata(), Some("build.7"));
|
||||
assert_eq!(without.build_metadata(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stages_are_recognised_from_the_first_identifier() {
|
||||
assert_eq!(version("1.0.0").stage(), Stage::Stable);
|
||||
assert_eq!(version("1.0.0-alpha.3").stage(), Stage::Alpha);
|
||||
assert_eq!(version("1.0.0-a.2").stage(), Stage::Alpha);
|
||||
assert_eq!(version("1.0.0-beta.1").stage(), Stage::Beta);
|
||||
assert_eq!(version("1.0.0-rc.4").stage(), Stage::ReleaseCandidate);
|
||||
assert_eq!(version("1.0.0-preview.2").stage(), Stage::ReleaseCandidate);
|
||||
assert_eq!(version("1.0.0-spike-1").stage(), Stage::Unknown);
|
||||
assert_eq!(version("1.0.0-7").stage(), Stage::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_display() {
|
||||
for text in [
|
||||
"0.2.1",
|
||||
"1.0.0-alpha.1",
|
||||
"3.17.0+build-9",
|
||||
"10.0.12-rc.2+ci.9",
|
||||
] {
|
||||
let parsed = version(text);
|
||||
let rendered = parsed.to_string();
|
||||
assert_eq!(version(&rendered), parsed, "{text} 回读不一致");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_v_prefix_and_reports_prerelease() {
|
||||
assert_eq!(version("v1.4.2"), version("1.4.2"));
|
||||
assert_eq!(version("V1.4.2"), version("1.4.2"));
|
||||
assert!(!version("1.4.2").is_prerelease());
|
||||
assert!(version("1.4.2-rc.1").is_prerelease());
|
||||
assert_eq!(version("1.4.2-rc.1").prerelease().as_deref(), Some("rc.1"));
|
||||
assert_eq!(version("1.4.2").prerelease(), None);
|
||||
assert_eq!(
|
||||
(
|
||||
version("1.4.2").major(),
|
||||
version("1.4.2").minor(),
|
||||
version("1.4.2").patch()
|
||||
),
|
||||
(1, 4, 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_semver_input() {
|
||||
for text in [
|
||||
"",
|
||||
"1",
|
||||
"1.2",
|
||||
"1.2.3.4",
|
||||
"01.2.3",
|
||||
"1.02.3",
|
||||
"1.2.03",
|
||||
"1.2.3-",
|
||||
"1.2.3-alpha..1",
|
||||
"1.2.3+",
|
||||
"1.2.3-é",
|
||||
"1.-2.3",
|
||||
"1.2.x",
|
||||
"1.2.3-01",
|
||||
] {
|
||||
assert!(
|
||||
Version::parse(text).is_err(),
|
||||
"`{text}` 不该被当成合法版本号"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_error_carries_input_and_reason() {
|
||||
let error = Version::parse("1.2.x").expect_err("应失败");
|
||||
assert_eq!(error.input(), "1.2.x");
|
||||
assert!(!error.reason().is_empty());
|
||||
assert!(error.to_string().contains("1.2.x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_builds_a_plain_release() {
|
||||
assert_eq!(Version::new(0, 2, 1), version("0.2.1"));
|
||||
assert!(!Version::new(0, 2, 1).is_prerelease());
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! 下载产物的校验:长度(截断)+ 摘要(内容),不符即拒绝安装。
|
||||
//!
|
||||
//! 承 `download` 的规矩「先校验后落盘」:包体先落在暂存目录的中间文件里,长度与摘要都对上
|
||||
//! 才算 [`StagedPackage`];对不上就**立刻删掉**,绝不让它有机会顶到目标路径上。
|
||||
//!
|
||||
//! 两道判据的顺序是有意的:先比长度(能抓住截断,且不需要读全文件),再比摘要(能抓住
|
||||
//! 「长度对但内容错」)。顺序反过来会让「被截断的包」白白多读一遍盘。
|
||||
//!
|
||||
//! 长度判据用「**恰好等于**」而不是「不小于」:清单里的 `size` 是我们自己发的字节数,
|
||||
//! 多了同样是错的(比如拼接了两个包),多一个字节也不许装。
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::download::verify::{digest_file, matches};
|
||||
use crate::download::Integrity;
|
||||
|
||||
use super::error::{UpdateError, UpdateErrorKind};
|
||||
use super::manifest::Artifact;
|
||||
use super::source::UpdateSource;
|
||||
|
||||
/// 暂存文件的进程内序号(同一进程多次更新不撞名)。
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// 校验通过的暂存包。
|
||||
///
|
||||
/// `Drop` 时删掉暂存文件(装好之后 `rename` 已经把它搬走,删除是无操作);
|
||||
/// 因此「校验失败要清理」这件事不需要调用方记得做。
|
||||
#[derive(Debug)]
|
||||
pub struct StagedPackage {
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
integrity: Integrity,
|
||||
}
|
||||
|
||||
impl StagedPackage {
|
||||
/// 暂存文件路径。
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// 实际字节数(已与清单核对过,等于清单声明的 `size`)。
|
||||
#[must_use]
|
||||
pub const fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// 校验用的摘要。
|
||||
#[must_use]
|
||||
pub fn integrity(&self) -> &Integrity {
|
||||
&self.integrity
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StagedPackage {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 从源取包体 → 落暂存 → 长度与摘要双检。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 取包失败透传 [`UpdateError`];长度不符是 [`UpdateErrorKind::SizeMismatch`],
|
||||
/// 摘要不符是 [`UpdateErrorKind::DigestMismatch`]。失败时暂存文件已被删掉。
|
||||
pub fn stage(
|
||||
source: &dyn UpdateSource,
|
||||
artifact: &Artifact,
|
||||
staging_dir: &Path,
|
||||
) -> Result<StagedPackage, UpdateError> {
|
||||
fs::create_dir_all(staging_dir).map_err(|error| UpdateError::io_at(staging_dir, &error))?;
|
||||
let path = staging_dir.join(format!(
|
||||
"{}-{}.ullupd",
|
||||
std::process::id(),
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let outcome = source
|
||||
.fetch_artifact(artifact, &path)
|
||||
.and_then(|_| verify_file(&path, &artifact.integrity, artifact.size));
|
||||
match outcome {
|
||||
Ok(size) => Ok(StagedPackage {
|
||||
path,
|
||||
size,
|
||||
integrity: artifact.integrity.clone(),
|
||||
}),
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&path);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验一个已经落盘的文件:长度必须**恰好**等于 `expected_size`,摘要必须相等。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读不到文件/读失败是 [`UpdateErrorKind::Io`];长度不符是 `SizeMismatch`;
|
||||
/// 摘要不符是 `DigestMismatch`。
|
||||
pub fn verify_file(
|
||||
path: &Path,
|
||||
integrity: &Integrity,
|
||||
expected_size: u64,
|
||||
) -> Result<u64, UpdateError> {
|
||||
let metadata = fs::metadata(path).map_err(|error| UpdateError::io_at(path, &error))?;
|
||||
let actual = metadata.len();
|
||||
if actual != expected_size {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::SizeMismatch,
|
||||
format!(
|
||||
"包体 {actual} 字节,清单声明 {expected_size} 字节(差 {} 字节;短了通常是下载被截断)",
|
||||
expected_size.abs_diff(actual)
|
||||
),
|
||||
)
|
||||
.with_path(path));
|
||||
}
|
||||
let digest =
|
||||
digest_file(path, integrity.algo).map_err(|error| UpdateError::io_at(path, &error))?;
|
||||
if !matches(&integrity.hex, &digest) {
|
||||
return Err(UpdateError::new(
|
||||
UpdateErrorKind::DigestMismatch,
|
||||
format!(
|
||||
"{} 摘要不符:清单 {} / 实际 {}(拒绝安装)",
|
||||
integrity.algo.as_str(),
|
||||
integrity.short_prefix(),
|
||||
&digest[..digest.len().min(8)]
|
||||
),
|
||||
)
|
||||
.with_path(path));
|
||||
}
|
||||
Ok(actual)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! 语义化版本:解析、比较、预发布阶段识别。
|
||||
//!
|
||||
//! 排序规则严格按 semver 2.0 §11:
|
||||
//!
|
||||
//! 1. 主 / 次 / 修订号按**数值**比(不是字符串比,所以 `0.9.0 < 0.10.0`);
|
||||
//! 2. 带预发布标识的版本**小于**同号正式版(`1.0.0-rc.1 < 1.0.0`);
|
||||
//! 3. 预发布标识逐段比:纯数字段按数值比、其余按 ASCII 字典序比;纯数字段**小于**任何
|
||||
//! 非数字段;前缀相同时段数少的更小(`1.0.0-alpha < 1.0.0-alpha.1`);
|
||||
//! 4. 构建元数据(`+` 之后)**既不参与比较也不参与相等判定**。
|
||||
//!
|
||||
//! `alpha < beta < rc` 不是特例:ASCII 里 `a < b < r`,规则 3 自然得出
|
||||
//! `1.0.0-alpha.1 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0`。**别**在这里加「阶段权重表」,
|
||||
//! 一旦加了,`1.0.0-preview` 与 `1.0.0-rc` 的相对顺序就和 semver 生态不一致了。
|
||||
//!
|
||||
//! 拒绝的写法(宁可解析失败,也别让「看起来像版本」的字符串混进清单):段数不是三段、
|
||||
//! 前导零(`01.2.3`)、空标识段(`1.0.0-`、`1.0.0-alpha..1`)、非 ASCII 标识字符、
|
||||
//! 留空的构建元数据。唯一放宽的是可选前缀 `v`(`v1.4.2`),因为 git tag 普遍这么写。
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
|
||||
/// 版本号解析失败。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VersionError {
|
||||
input: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
impl VersionError {
|
||||
/// 原始输入(已 trim,但保留 `v` 前缀这种细节)。
|
||||
#[must_use]
|
||||
pub fn input(&self) -> &str {
|
||||
&self.input
|
||||
}
|
||||
|
||||
/// 具体原因。
|
||||
#[must_use]
|
||||
pub fn reason(&self) -> &str {
|
||||
&self.reason
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VersionError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "版本号 `{}` 不合法:{}", self.input, self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VersionError {}
|
||||
|
||||
fn invalid(input: &str, reason: impl Into<String>) -> VersionError {
|
||||
VersionError {
|
||||
input: input.trim().to_string(),
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 预发布标识的一段。
|
||||
///
|
||||
/// 刻意不对外暴露:外部能构造出 `Alpha("01")` 这种违反 semver 不变量的值,
|
||||
/// 排序与相等就自相矛盾了。读方向只给 [`Version::prerelease`] 的字符串形式。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Identifier {
|
||||
Numeric(u64),
|
||||
Alpha(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Identifier {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Numeric(value) => write!(f, "{value}"),
|
||||
Self::Alpha(text) => f.write_str(text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Identifier {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match (self, other) {
|
||||
(Self::Numeric(left), Self::Numeric(right)) => left.cmp(right),
|
||||
(Self::Numeric(_), Self::Alpha(_)) => Ordering::Less,
|
||||
(Self::Alpha(_), Self::Numeric(_)) => Ordering::Greater,
|
||||
(Self::Alpha(left), Self::Alpha(right)) => left.cmp(right),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Identifier {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// 预发布阶段(渠道准入判定用,见 [`crate::update::Channel::admits_stage`])。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Stage {
|
||||
/// 正式版(没有预发布标识)。
|
||||
Stable,
|
||||
/// `rc` / `pre` / `preview`。
|
||||
ReleaseCandidate,
|
||||
/// `beta` / `b`。
|
||||
Beta,
|
||||
/// `alpha` / `a`。
|
||||
Alpha,
|
||||
/// 认不出的预发布标识(只有 `nightly` 渠道收)。
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
/// 稳定字符串 id。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Stable => "stable",
|
||||
Self::ReleaseCandidate => "rc",
|
||||
Self::Beta => "beta",
|
||||
Self::Alpha => "alpha",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本号。
|
||||
///
|
||||
/// 字段私有:构造只走 [`Version::parse`] 与 [`Version::new`],不变量(前导零、空标识段)
|
||||
/// 因此只需要守一处。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Version {
|
||||
major: u64,
|
||||
minor: u64,
|
||||
patch: u64,
|
||||
pre: Vec<Identifier>,
|
||||
build: Option<String>,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
/// 正式版(`major.minor.patch`,无预发布、无构建元数据)。
|
||||
#[must_use]
|
||||
pub const fn new(major: u64, minor: u64, patch: u64) -> Self {
|
||||
Self {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
pre: Vec::new(),
|
||||
build: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 `MAJOR.MINOR.PATCH[-PRE][+BUILD]`(可选前缀 `v`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 任何不符合 semver 的写法都返回 [`VersionError`],错误里带原始输入与原因。
|
||||
pub fn parse(text: &str) -> Result<Self, VersionError> {
|
||||
let trimmed = text.trim();
|
||||
let body = trimmed
|
||||
.strip_prefix('v')
|
||||
.or_else(|| trimmed.strip_prefix('V'))
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
let (body, build) = match body.split_once('+') {
|
||||
Some((head, build)) => (head, Some(build)),
|
||||
None => (body, None),
|
||||
};
|
||||
if let Some(build) = build {
|
||||
if build.split('.').any(|segment| !is_identifier(segment)) {
|
||||
return Err(invalid(text, "构建元数据含非法字符或空段"));
|
||||
}
|
||||
}
|
||||
|
||||
let (core, pre) = match body.split_once('-') {
|
||||
Some((head, pre)) => (head, Some(pre)),
|
||||
None => (body, None),
|
||||
};
|
||||
|
||||
let mut numbers = core.split('.');
|
||||
let major = segment_number(text, numbers.next(), "主版本号")?;
|
||||
let minor = segment_number(text, numbers.next(), "次版本号")?;
|
||||
let patch = segment_number(text, numbers.next(), "修订号")?;
|
||||
if numbers.next().is_some() {
|
||||
return Err(invalid(text, "只接受三段数字(主.次.修订)"));
|
||||
}
|
||||
|
||||
let pre = match pre {
|
||||
Some(pre) => parse_prerelease(text, pre)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
pre,
|
||||
build: build.map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
/// 主版本号。
|
||||
#[must_use]
|
||||
pub const fn major(&self) -> u64 {
|
||||
self.major
|
||||
}
|
||||
|
||||
/// 次版本号。
|
||||
#[must_use]
|
||||
pub const fn minor(&self) -> u64 {
|
||||
self.minor
|
||||
}
|
||||
|
||||
/// 修订号。
|
||||
#[must_use]
|
||||
pub const fn patch(&self) -> u64 {
|
||||
self.patch
|
||||
}
|
||||
|
||||
/// 预发布标识的原始写法(`None` = 正式版)。
|
||||
#[must_use]
|
||||
pub fn prerelease(&self) -> Option<String> {
|
||||
if self.pre.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
self.pre
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("."),
|
||||
)
|
||||
}
|
||||
|
||||
/// 构建元数据(`+` 之后,不参与比较)。
|
||||
#[must_use]
|
||||
pub fn build_metadata(&self) -> Option<&str> {
|
||||
self.build.as_deref()
|
||||
}
|
||||
|
||||
/// 是否预发布。
|
||||
#[must_use]
|
||||
pub fn is_prerelease(&self) -> bool {
|
||||
!self.pre.is_empty()
|
||||
}
|
||||
|
||||
/// 预发布阶段(正式版为 [`Stage::Stable`])。
|
||||
#[must_use]
|
||||
pub fn stage(&self) -> Stage {
|
||||
match self.pre.first() {
|
||||
None => Stage::Stable,
|
||||
Some(Identifier::Numeric(_)) => Stage::Unknown,
|
||||
Some(Identifier::Alpha(name)) => match name.as_str() {
|
||||
"alpha" | "a" => Stage::Alpha,
|
||||
"beta" | "b" => Stage::Beta,
|
||||
"rc" | "pre" | "preview" => Stage::ReleaseCandidate,
|
||||
_ => Stage::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_number(whole: &str, segment: Option<&str>, label: &str) -> Result<u64, VersionError> {
|
||||
let Some(segment) = segment else {
|
||||
return Err(invalid(whole, "段数不足:需要 `主.次.修订` 三段"));
|
||||
};
|
||||
if segment.is_empty() {
|
||||
return Err(invalid(whole, format!("{label}为空")));
|
||||
}
|
||||
if !segment.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(invalid(whole, format!("{label}含非数字字符")));
|
||||
}
|
||||
if segment.len() > 1 && segment.starts_with('0') {
|
||||
return Err(invalid(whole, format!("{label}不得有前导零")));
|
||||
}
|
||||
segment
|
||||
.parse::<u64>()
|
||||
.map_err(|_| invalid(whole, format!("{label}超出 u64 范围")))
|
||||
}
|
||||
|
||||
fn is_identifier(segment: &str) -> bool {
|
||||
!segment.is_empty()
|
||||
&& segment
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
}
|
||||
|
||||
fn parse_prerelease(whole: &str, pre: &str) -> Result<Vec<Identifier>, VersionError> {
|
||||
let mut out = Vec::new();
|
||||
for segment in pre.split('.') {
|
||||
if !is_identifier(segment) {
|
||||
return Err(invalid(whole, "预发布标识含非法字符或空段"));
|
||||
}
|
||||
if segment.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
if segment.len() > 1 && segment.starts_with('0') {
|
||||
return Err(invalid(whole, "预发布数字段不得有前导零"));
|
||||
}
|
||||
let value = segment
|
||||
.parse::<u64>()
|
||||
.map_err(|_| invalid(whole, "预发布数字段超出 u64 范围"))?;
|
||||
out.push(Identifier::Numeric(value));
|
||||
} else {
|
||||
out.push(Identifier::Alpha(segment.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn compare_prerelease(left: &[Identifier], right: &[Identifier]) -> Ordering {
|
||||
match (left.is_empty(), right.is_empty()) {
|
||||
(true, true) => Ordering::Equal,
|
||||
(true, false) => Ordering::Greater,
|
||||
(false, true) => Ordering::Less,
|
||||
(false, false) => left.cmp(right),
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Version {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.major
|
||||
.cmp(&other.major)
|
||||
.then_with(|| self.minor.cmp(&other.minor))
|
||||
.then_with(|| self.patch.cmp(&other.patch))
|
||||
.then_with(|| compare_prerelease(&self.pre, &other.pre))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Version {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Version {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cmp(other) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Version {}
|
||||
|
||||
impl fmt::Display for Version {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
|
||||
if !self.pre.is_empty() {
|
||||
f.write_str("-")?;
|
||||
for (index, identifier) in self.pre.iter().enumerate() {
|
||||
if index > 0 {
|
||||
f.write_str(".")?;
|
||||
}
|
||||
write!(f, "{identifier}")?;
|
||||
}
|
||||
}
|
||||
if let Some(build) = &self.build {
|
||||
write!(f, "+{build}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Version {
|
||||
type Err = VersionError;
|
||||
|
||||
fn from_str(text: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(text)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user