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

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