From 2e718af711a930dcb087197cb5b9a08ee6cd83e5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 6 Nov 2026 19:16:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=96=B0=E5=A2=9E=20update=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=81=9A=E4=B8=8E=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=BA=90=E6=97=A0=E5=85=B3=E7=9A=84=E8=87=AA=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=86=85=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新源还没拍板,所以先把与源无关的那半边做掉:语义化版本比较、发布清单模型、 渠道准入、下载校验、原子替换 + 回滚。源抽象成 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 占用文件、回滚与备份清理。 --- crates/core/src/lib.rs | 1 + crates/core/src/update/channel.rs | 113 ++++++ crates/core/src/update/checker.rs | 204 ++++++++++ crates/core/src/update/error.rs | 128 ++++++ crates/core/src/update/install.rs | 268 +++++++++++++ crates/core/src/update/manifest.rs | 263 +++++++++++++ crates/core/src/update/mod.rs | 66 ++++ crates/core/src/update/source.rs | 174 +++++++++ crates/core/src/update/target.rs | 196 ++++++++++ crates/core/src/update/tests/channel_cases.rs | 57 +++ crates/core/src/update/tests/checker_cases.rs | 245 ++++++++++++ crates/core/src/update/tests/install_cases.rs | 192 +++++++++ .../core/src/update/tests/manifest_cases.rs | 258 +++++++++++++ crates/core/src/update/tests/mod.rs | 22 ++ crates/core/src/update/tests/source_cases.rs | 179 +++++++++ crates/core/src/update/tests/support.rs | 82 ++++ crates/core/src/update/tests/target_cases.rs | 91 +++++ crates/core/src/update/tests/verify_cases.rs | 138 +++++++ crates/core/src/update/tests/version_cases.rs | 135 +++++++ crates/core/src/update/verify.rs | 134 +++++++ crates/core/src/update/version.rs | 363 ++++++++++++++++++ 21 files changed, 3309 insertions(+) create mode 100644 crates/core/src/update/channel.rs create mode 100644 crates/core/src/update/checker.rs create mode 100644 crates/core/src/update/error.rs create mode 100644 crates/core/src/update/install.rs create mode 100644 crates/core/src/update/manifest.rs create mode 100644 crates/core/src/update/mod.rs create mode 100644 crates/core/src/update/source.rs create mode 100644 crates/core/src/update/target.rs create mode 100644 crates/core/src/update/tests/channel_cases.rs create mode 100644 crates/core/src/update/tests/checker_cases.rs create mode 100644 crates/core/src/update/tests/install_cases.rs create mode 100644 crates/core/src/update/tests/manifest_cases.rs create mode 100644 crates/core/src/update/tests/mod.rs create mode 100644 crates/core/src/update/tests/source_cases.rs create mode 100644 crates/core/src/update/tests/support.rs create mode 100644 crates/core/src/update/tests/target_cases.rs create mode 100644 crates/core/src/update/tests/verify_cases.rs create mode 100644 crates/core/src/update/tests/version_cases.rs create mode 100644 crates/core/src/update/verify.rs create mode 100644 crates/core/src/update/version.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 16966bf..626b99d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -22,3 +22,4 @@ pub mod game; pub mod launch; pub mod platform; pub mod setting; +pub mod update; diff --git a/crates/core/src/update/channel.rs b/crates/core/src/update/channel.rs new file mode 100644 index 0000000..56c9142 --- /dev/null +++ b/crates/core/src/update/channel.rs @@ -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 { + 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::>() + .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::parse(text) + } +} diff --git a/crates/core/src/update/checker.rs b/crates/core/src/update/checker.rs new file mode 100644 index 0000000..56f0d01 --- /dev/null +++ b/crates/core/src/update/checker.rs @@ -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, + }, + /// 有可装的更新。 + Available(Box), + /// 有更高的版本,但里面没有本平台的产物。 + MissingArtifact { + /// 那个更高版本(没有本平台产物的那份发布)。 + release: Box, + }, +} + +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 { + source: S, + target: Target, +} + +impl UpdateChecker { + /// 用显式目标平台构造(跨平台代查、测试都靠它)。 + #[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 { + 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::>(); + let latest = admitted.iter().map(|release| release.version.clone()).max(); + let mut candidates = admitted + .into_iter() + .filter(|release| release.version > *current) + .collect::>(); + 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 { + stage(&self.source, &candidate.artifact, staging_dir) + } +} + +/// 该渠道准入的全部发布(不看版本高低)。 +fn admitted(manifest: &ReleaseManifest, channel: Channel) -> impl Iterator { + 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 + } +} diff --git a/crates/core/src/update/error.rs b/crates/core/src/update/error.rs new file mode 100644 index 0000000..a02ce17 --- /dev/null +++ b/crates/core/src/update/error.rs @@ -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, +} + +impl UpdateError { + /// 构造错误。 + #[must_use] + pub fn new(kind: UpdateErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + path: None, + } + } + + /// 清单不合法。 + #[must_use] + pub fn invalid_manifest(message: impl Into) -> Self { + Self::new(UpdateErrorKind::InvalidManifest, message) + } + + /// 标注出错路径。 + #[must_use] + pub fn with_path(mut self, path: impl Into) -> 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 for UpdateError { + fn from(error: VersionError) -> Self { + Self::new(UpdateErrorKind::InvalidVersion, error.to_string()) + } +} diff --git a/crates/core/src/update/install.rs b/crates/core/src/update/install.rs new file mode 100644 index 0000000..0a68d39 --- /dev/null +++ b/crates/core/src/update/install.rs @@ -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, + /// 新版本字节数。 + 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, +} + +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 { + 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) -> InstallFailure { + InstallFailure { + error, + rolled_back, + backup_left, + } +} + +/// 旧版本的备份路径:`/.<文件名>.ullold--`。 +/// +/// 点号开头在 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 + ) +} diff --git a/crates/core/src/update/manifest.rs b/crates/core/src/update/manifest.rs new file mode 100644 index 0000000..59e2937 --- /dev/null +++ b/crates/core/src/update/manifest.rs @@ -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, +} + +impl ReleaseManifest { + /// 解析清单 JSON 并做完整校验。 + /// + /// # Errors + /// + /// JSON 语法错、字段名拼错、版本号/摘要/长度不合法都返回 [`UpdateError`], + /// 分类是 [`UpdateErrorKind::InvalidManifest`] 或 [`UpdateErrorKind::InvalidVersion`]。 + pub fn from_json(text: &str) -> Result { + 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, + /// 更新说明(纯文本,仅展示)。 + pub notes: Option, + /// 是否强制更新(不看版本号也得升)。 + pub force: bool, + /// 客户端最低支持版本:当前版本低于它就必须升(发布侧收回老版本的支持)。 + pub min_supported: Option, + /// 各平台的产物。清单里的顺序 = 同一目标多形态时的优先级。 + pub artifacts: Vec, +} + +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 { + 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 { + 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, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawRelease { + version: String, + channel: Option, + released_at: Option, + notes: Option, + #[serde(default)] + force: bool, + min_supported: Option, + #[serde(default)] + artifacts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawArtifact { + target: String, + format: String, + url: String, + size: u64, + sha256: Option, + sha1: Option, +} diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs new file mode 100644 index 0000000..373466c --- /dev/null +++ b/crates/core/src/update/mod.rs @@ -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; diff --git a/crates/core/src/update/source.rs b/crates/core/src/update/source.rs new file mode 100644 index 0000000..6029853 --- /dev/null +++ b/crates/core/src/update/source.rs @@ -0,0 +1,174 @@ +//! 更新源抽象:清单从哪来、包体从哪来,全部收在一个 trait 后面。 +//! +//! 更新源(CDN / 对象存储 / GitHub Release / 内网文件服务器)**还没拍板**,所以这里 +//! 刻意不写死任何 URL、任何厂商名:core 只认 [`UpdateSource`] 三个方法,接哪家由上层 +//! 注入。将来接 HTTP 源时,实现里用 `tokio`/`reqwest` 或 +//! [`crate::download::blocking::block_on`] 都行,trait 形状不用动。 +//! +//! 内置的实现只有一个:[`DirSource`](本地目录 / `file://`),用途是测试、内网手工分发、 +//! 以及给将来的 HTTP 实现当对照物。 +//! +//! # 本地源的目录约定 +//! +//! ```text +//! /stable.json 渠道清单(文件名 = 渠道名 + .json) +//! /beta.json +//! /<产物文件> 清单里 url 写相对路径时,相对 root +//! ``` +//! +//! 清单里的 `url` 支持两种写法:`file://` 绝对地址,或相对 `` 的路径。 +//! 出现 `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, UpdateError>; + + /// 把产物取到 `dest`,返回写入的字节数。 + /// + /// 实现只管「把字节放到 dest」;长度与摘要校验由 [`crate::update::verify`] 统一做, + /// 别在实现里各写一套。 + /// + /// # Errors + /// + /// 源里没有这个文件、读失败、写 `dest` 失败都返回 [`UpdateError`]。 + fn fetch_artifact(&self, artifact: &Artifact, dest: &Path) -> Result; +} + +/// 本地目录源(也接受 `file://` 根地址)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirSource { + root: PathBuf, + id: String, +} + +impl DirSource { + /// 用本地目录构造。 + #[must_use] + pub fn new(root: impl Into) -> 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 { + 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 { + 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, 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 { + 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)), + } + } +} diff --git a/crates/core/src/update/target.rs b/crates/core/src/update/target.rs new file mode 100644 index 0000000..292d31e --- /dev/null +++ b/crates/core/src/update/target.rs @@ -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 { + 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 { + 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 { + 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 { + 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()) + } +} diff --git a/crates/core/src/update/tests/channel_cases.rs b/crates/core/src/update/tests/channel_cases.rs new file mode 100644 index 0000000..d4f7e02 --- /dev/null +++ b/crates/core/src/update/tests/channel_cases.rs @@ -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::().expect("回读"), channel); + assert_eq!(channel.to_string(), channel.as_str()); + } +} diff --git a/crates/core/src/update/tests/checker_cases.rs b/crates/core/src/update/tests/checker_cases.rs new file mode 100644 index 0000000..456e70c --- /dev/null +++ b/crates/core/src/update/tests/checker_cases.rs @@ -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:")); +} diff --git a/crates/core/src/update/tests/install_cases.rs b/crates/core/src/update/tests/install_cases.rs new file mode 100644 index 0000000..64a2365 --- /dev/null +++ b/crates/core/src/update/tests/install_cases.rs @@ -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 { + 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::>(); + 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), + "同一进程两次调用不能撞名" + ); +} diff --git a/crates/core/src/update/tests/manifest_cases.rs b/crates/core/src/update/tests/manifest_cases.rs new file mode 100644 index 0000000..5e6a6b0 --- /dev/null +++ b/crates/core/src/update/tests/manifest_cases.rs @@ -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()); +} diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs new file mode 100644 index 0000000..b26497b --- /dev/null +++ b/crates/core/src/update/tests/mod.rs @@ -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; diff --git a/crates/core/src/update/tests/source_cases.rs b/crates/core/src/update/tests/source_cases.rs new file mode 100644 index 0000000..67f8477 --- /dev/null +++ b/crates/core/src/update/tests/source_cases.rs @@ -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); +} diff --git a/crates/core/src/update/tests/support.rs b/crates/core/src/update/tests/support.rs new file mode 100644 index 0000000..a66e13c --- /dev/null +++ b/crates/core/src/update/tests/support.rs @@ -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(",") + ) +} diff --git a/crates/core/src/update/tests/target_cases.rs b/crates/core/src/update/tests/target_cases.rs new file mode 100644 index 0000000..a178e10 --- /dev/null +++ b/crates/core/src/update/tests/target_cases.rs @@ -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}"); + } +} diff --git a/crates/core/src/update/tests/verify_cases.rs b/crates/core/src/update/tests/verify_cases.rs new file mode 100644 index 0000000..4236787 --- /dev/null +++ b/crates/core/src/update/tests/verify_cases.rs @@ -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 { + 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 落地后应自行清理"); +} diff --git a/crates/core/src/update/tests/version_cases.rs b/crates/core/src/update/tests/version_cases.rs new file mode 100644 index 0000000..4234931 --- /dev/null +++ b/crates/core/src/update/tests/version_cases.rs @@ -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()); +} diff --git a/crates/core/src/update/verify.rs b/crates/core/src/update/verify.rs new file mode 100644 index 0000000..df3bc29 --- /dev/null +++ b/crates/core/src/update/verify.rs @@ -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 { + 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 { + 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) +} diff --git a/crates/core/src/update/version.rs b/crates/core/src/update/version.rs new file mode 100644 index 0000000..d83addb --- /dev/null +++ b/crates/core/src/update/version.rs @@ -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) -> 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 { + 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, + build: Option, +} + +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 { + 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 { + if self.pre.is_empty() { + return None; + } + Some( + self.pre + .iter() + .map(ToString::to_string) + .collect::>() + .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 { + 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::() + .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, 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::() + .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 { + 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::parse(text) + } +}