feat(core): 新增 update 模块,做与更新源无关的自更新内核

更新源还没拍板,所以先把与源无关的那半边做掉:语义化版本比较、发布清单模型、
渠道准入、下载校验、原子替换 + 回滚。源抽象成 UpdateSource trait(清单 + 包体两个
方法),core 里没有任何 CDN 常量;内置的 DirSource 吃本地目录或 file://,既是测试
实现也是将来 HTTP 源的对照物。

取舍:
- 版本比较严格按 semver 2.0 §11:数值比(0.9 < 0.10)、预发布 < 同号正式版、
  预发布标识逐段比(alpha < beta < rc 是 ASCII 序的自然结果,不另立阶段权重表)、
  构建元数据不参与排序与相等。
- 渠道准入两道判据:清单自报渠道的阶梯比较 + 版本号里的预发布阶段。清单可能写错,
  版本号不会,所以正式版渠道即使拿到一份自称 stable 的 rc 也不会提示更新。
- 校验两道判据且顺序有意:先比长度(抓截断,不必读全文件)再比摘要(抓内容错),
  长度要求**恰好**相等;失败即删暂存文件。StagedPackage 只能由 stage() 产出,
  于是「未校验的包」在类型上就进不了安装路径。
- 替换跨平台统一成「旧文件改名挪开 → 新文件搬进来 → 失败把备份搬回去」:Windows 上
  覆盖运行中的 exe 不存在,改名是唯一可用动作,而改名也可能被不共享删除的句柄拒绝,
  所以失败必须能回滚。Unix 也照这个顺序跑,避免回滚路径只有 Windows 被真正跑过。
- core 零 UI 依赖不变:只有 serde/serde_json + 复用的 sha1/sha2,无新依赖,
  Cargo.toml 未改;不接 presenter、不接 UI。

单测 62 条(crates/core/src/update/tests/):版本边界、清单校验、渠道准入、
摘要不符、包体截断(短了/多了)、无权限写目标目录、Windows 占用文件、回滚与备份清理。
This commit is contained in:
AskaEth committed 2026-11-06 19:16:15 +08:00
1 parent 156abfe38b
commit 2e718af711
21 files changed
+3309

No files matched your search

+363
View File
@@ -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)
}
}