更新源还没拍板,所以先把与源无关的那半边做掉:语义化版本比较、发布清单模型、 渠道准入、下载校验、原子替换 + 回滚。源抽象成 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 占用文件、回滚与备份清理。
364 lines
11 KiB
Rust
364 lines
11 KiB
Rust
//! 语义化版本:解析、比较、预发布阶段识别。
|
||
//!
|
||
//! 排序规则严格按 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)
|
||
}
|
||
}
|