更新源还没拍板,所以先把与源无关的那半边做掉:语义化版本比较、发布清单模型、 渠道准入、下载校验、原子替换 + 回滚。源抽象成 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 占用文件、回滚与备份清理。
269 lines
10 KiB
Rust
269 lines
10 KiB
Rust
//! 原子替换:先把旧文件改名挪开,再把新文件搬进来;任一步失败都能回滚。
|
||
//!
|
||
//! # 为什么不用「直接覆盖」
|
||
//!
|
||
//! Windows 上**正在运行的 exe** 不能被覆盖、也不能被删除(映像被占用,改字节这条路根本
|
||
//! 不存在);`std::fs::rename` 走 `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`,目标只要被任何
|
||
//! 「不共享删除」的句柄占着就报共享冲突(`os error 32`)。剩下唯一可用的动作是**改名**:
|
||
//! 把旧文件挪到同目录的备份名,让新文件顶替原来的路径。而改名本身也可能被拒绝(杀软扫描
|
||
//! 句柄、编辑器打开、另一个进程正在写),所以失败必须能回到旧版本——这正是本模块存在的理由。
|
||
//!
|
||
//! 三步写法,Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后,
|
||
//! 回滚路径只有 Windows 被真正跑过,最容易坏的那一半就没人测了):
|
||
//!
|
||
//! ```text
|
||
//! 1. dest 存在 → rename(dest → <同目录备份>) 旧版本先安全落地
|
||
//! 2. rename(暂存包 → dest) 新版本就位
|
||
//! 3a. 成功 → 返回 InstallReceipt(备份留着,等新版真的能起来再删)
|
||
//! 3b. 失败 → rename(备份 → dest) 回滚,并把「回滚是否成功」如实报给调用方
|
||
//! ```
|
||
//!
|
||
//! # 本模块**不**负责的事
|
||
//!
|
||
//! * **不判断现在能不能替换**。正在跑的 exe 只能由「退出自身 → 由另一个进程搬运」这条
|
||
//! 路换掉(HMCL 的 `--apply-to` 模式,见 analyse/07 §4.3)。这里只保证搬一半失败能回滚。
|
||
//! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器,
|
||
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
|
||
//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再
|
||
//! [`InstallReceipt::remove_backup`])。
|
||
//!
|
||
//! # 单测覆盖到哪
|
||
//!
|
||
//! 占用场景是**真的造出来**的:测试里用 `share_mode(FILE_SHARE_READ)` 打开目标文件
|
||
//! (**不带** `FILE_SHARE_DELETE`,与 Windows 加载器打开正在运行的映像时一致),
|
||
//! 让 `rename` 撞共享冲突,于是在 Windows 上能看到「重试 → 仍失败 → 旧版本完好」。
|
||
//! 那条用例是 `#[cfg(windows)]`:Unix 允许改名打开中的文件,本来就该成功。
|
||
//!
|
||
//! 但它不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。
|
||
//! 另外注意:`std::fs::File::open` **默认共享删除**,用它造的「占用」不生效,
|
||
//! 写这类用例时必须显式指定 `share_mode`。
|
||
|
||
use std::fmt;
|
||
use std::fs;
|
||
use std::io;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::time::Duration;
|
||
|
||
use super::error::UpdateError;
|
||
use super::verify::StagedPackage;
|
||
|
||
/// 同进程内备份文件名去重序号。
|
||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||
|
||
/// 替换策略:改名失败时重试几次、每次间隔多久。
|
||
///
|
||
/// 默认 5 次 × 80ms:足够扛过杀软扫描/索引器短暂占用,又不至于让用户在「更新」按钮上
|
||
/// 干等半分钟。真正长期的占用(另一个启动器实例在跑)重试多少次都没用,直接报错更诚实。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct ReplacePolicy {
|
||
/// 总尝试次数(含首次),`0` 按 `1` 处理。
|
||
pub attempts: u32,
|
||
/// 两次尝试之间的等待。
|
||
pub delay: Duration,
|
||
}
|
||
|
||
impl Default for ReplacePolicy {
|
||
fn default() -> Self {
|
||
Self {
|
||
attempts: 5,
|
||
delay: Duration::from_millis(80),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ReplacePolicy {
|
||
/// 构造。
|
||
#[must_use]
|
||
pub const fn new(attempts: u32, delay: Duration) -> Self {
|
||
Self { attempts, delay }
|
||
}
|
||
}
|
||
|
||
/// 替换成功后的收据。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct InstallReceipt {
|
||
/// 新版本所在的路径。
|
||
pub dest: PathBuf,
|
||
/// 旧版本被挪到哪(原本没有文件时为 `None`)。
|
||
pub backup: Option<PathBuf>,
|
||
/// 新版本字节数。
|
||
pub bytes: u64,
|
||
}
|
||
|
||
impl InstallReceipt {
|
||
/// 删掉旧版本备份(新版启动成功之后调用)。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 删不掉(还占着、没权限)返回带路径的 [`UpdateError`]——这时**别**当成更新失败,
|
||
/// 只是留了个垃圾文件,下次启动还能再删。
|
||
pub fn remove_backup(&self) -> Result<(), UpdateError> {
|
||
let Some(backup) = &self.backup else {
|
||
return Ok(());
|
||
};
|
||
fs::remove_file(backup).map_err(|error| UpdateError::io_at(backup, &error))
|
||
}
|
||
|
||
/// 手动回滚:把备份搬回 `dest`(新版起不来时用)。成功后 `backup` 字段清空。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 没有备份时是空操作 `Ok(())`;搬不动返回 [`UpdateError`](此时 `backup` 保持不变,
|
||
/// 旧版本还在原位置,可以再试)。
|
||
pub fn rollback(&mut self, policy: ReplacePolicy) -> Result<(), UpdateError> {
|
||
let Some(backup) = self.backup.as_ref() else {
|
||
return Ok(());
|
||
};
|
||
rename_with_retry(backup, &self.dest, policy)
|
||
.map_err(|error| UpdateError::io_at(&self.dest, &error))?;
|
||
self.backup = None;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// 替换失败:原始错误 + 回滚结果。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct InstallFailure {
|
||
/// 原始失败。
|
||
pub error: UpdateError,
|
||
/// 旧版本是否已回到原路径(`true` = 目标没被破坏;本来没有旧文件时也算 `true`)。
|
||
pub rolled_back: bool,
|
||
/// 回滚失败时旧版本留在哪(`None` = 已回到原位或压根没有旧文件)。
|
||
pub backup_left: Option<PathBuf>,
|
||
}
|
||
|
||
impl InstallFailure {
|
||
/// 目标是否可能处于「既不是旧版也不是新版」的坏状态(需要人工介入)。
|
||
#[must_use]
|
||
pub const fn needs_manual_repair(&self) -> bool {
|
||
!self.rolled_back
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for InstallFailure {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
write!(f, "{}", self.error)?;
|
||
if self.rolled_back {
|
||
f.write_str("(已回滚,旧版本仍在原位)")
|
||
} else {
|
||
let where_ = self.backup_left.as_ref().map_or_else(String::new, |path| {
|
||
format!(",旧文件留在 {}", path.display())
|
||
});
|
||
write!(f, "(回滚失败,需要人工处理{where_})")
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for InstallFailure {}
|
||
|
||
/// 把校验过的暂存包装到 `dest`;失败则回滚,旧版本保持可用。
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// 目录建不出来、目标被占用、改名失败都返回 [`InstallFailure`],
|
||
/// 其中的 `rolled_back` 如实反映旧版本有没有回到原位。
|
||
pub fn install_staged(
|
||
staged: &StagedPackage,
|
||
dest: &Path,
|
||
policy: ReplacePolicy,
|
||
) -> Result<InstallReceipt, InstallFailure> {
|
||
if let Some(parent) = dest
|
||
.parent()
|
||
.filter(|parent| !parent.as_os_str().is_empty())
|
||
{
|
||
fs::create_dir_all(parent)
|
||
.map_err(|error| failed(UpdateError::io_at(parent, &error), true, None))?;
|
||
}
|
||
|
||
let mut backup = None;
|
||
if fs::symlink_metadata(dest).is_ok() {
|
||
let path = backup_path(dest);
|
||
rename_with_retry(dest, &path, policy)
|
||
.map_err(|error| failed(UpdateError::io_at(dest, &error), true, None))?;
|
||
backup = Some(path);
|
||
}
|
||
|
||
match rename_with_retry(staged.path(), dest, policy) {
|
||
Ok(()) => Ok(InstallReceipt {
|
||
dest: dest.to_path_buf(),
|
||
backup,
|
||
bytes: staged.size(),
|
||
}),
|
||
Err(error) => {
|
||
let error = UpdateError::io_at(dest, &error);
|
||
let Some(backup_path) = backup else {
|
||
// 目标原本不存在:没有旧版本要恢复,目标也没被写坏,
|
||
// 所以按「旧版本仍在原位」上报(新安装失败 ≠ 现有安装被破坏)。
|
||
return Err(failed(error, true, None));
|
||
};
|
||
match rename_with_retry(&backup_path, dest, policy) {
|
||
Ok(()) => Err(failed(error, true, None)),
|
||
Err(_) => Err(failed(error, false, Some(backup_path))),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn failed(error: UpdateError, rolled_back: bool, backup_left: Option<PathBuf>) -> InstallFailure {
|
||
InstallFailure {
|
||
error,
|
||
rolled_back,
|
||
backup_left,
|
||
}
|
||
}
|
||
|
||
/// 旧版本的备份路径:`<dest 同目录>/.<文件名>.ullold-<pid>-<seq>`。
|
||
///
|
||
/// 点号开头在 Unix 上是隐藏文件,也和 `download` 的 `.ullpart-*` 中间文件一个家族;
|
||
/// 必须同目录,因为 `rename` **不能跨卷**(Unix 返回 `EXDEV`,Windows 返回
|
||
/// `ERROR_NOT_SAME_DEVICE`——`std` 的 `fs::rename` 不带 `MOVEFILE_COPY_ALLOWED`,
|
||
/// 不会偷偷降级成复制)。
|
||
#[must_use]
|
||
pub fn backup_path(dest: &Path) -> PathBuf {
|
||
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
||
let name = dest.file_name().map_or_else(
|
||
|| String::from("ull"),
|
||
|name| name.to_string_lossy().into_owned(),
|
||
);
|
||
let backup = format!(".{name}.ullold-{}-{seq}", std::process::id());
|
||
match dest.parent() {
|
||
Some(parent) => parent.join(backup),
|
||
None => PathBuf::from(backup),
|
||
}
|
||
}
|
||
|
||
/// 改名,失败时按策略重试(占用类错误才值得重试)。
|
||
fn rename_with_retry(from: &Path, to: &Path, policy: ReplacePolicy) -> io::Result<()> {
|
||
let attempts = policy.attempts.max(1);
|
||
let mut last = None;
|
||
for attempt in 0..attempts {
|
||
match fs::rename(from, to) {
|
||
Ok(()) => return Ok(()),
|
||
Err(error) => {
|
||
let retryable = is_transient(&error) && attempt + 1 < attempts;
|
||
if !retryable {
|
||
return Err(error);
|
||
}
|
||
std::thread::sleep(policy.delay);
|
||
last = Some(error);
|
||
}
|
||
}
|
||
}
|
||
Err(last.unwrap_or_else(|| io::Error::other("rename 未执行")))
|
||
}
|
||
|
||
/// 是否属于「等一会儿可能就好了」的失败。
|
||
///
|
||
/// Windows 的共享冲突(`os error 32`)落在 `ErrorKind::Other` 里(`std` 没有专门的 kind),
|
||
/// 所以这里必须把 `Other` 也算上;`PermissionDenied` 覆盖 `ACCESS_DENIED`(杀软持有的句柄)。
|
||
fn is_transient(error: &io::Error) -> bool {
|
||
matches!(
|
||
error.kind(),
|
||
io::ErrorKind::PermissionDenied
|
||
| io::ErrorKind::Other
|
||
| io::ErrorKind::Interrupted
|
||
| io::ErrorKind::WouldBlock
|
||
)
|
||
}
|