feat(core): 新增 update 模块,做与更新源无关的自更新内核
更新源还没拍板,所以先把与源无关的那半边做掉:语义化版本比较、发布清单模型、 渠道准入、下载校验、原子替换 + 回滚。源抽象成 UpdateSource trait(清单 + 包体两个 方法),core 里没有任何 CDN 常量;内置的 DirSource 吃本地目录或 file://,既是测试 实现也是将来 HTTP 源的对照物。 取舍: - 版本比较严格按 semver 2.0 §11:数值比(0.9 < 0.10)、预发布 < 同号正式版、 预发布标识逐段比(alpha < beta < rc 是 ASCII 序的自然结果,不另立阶段权重表)、 构建元数据不参与排序与相等。 - 渠道准入两道判据:清单自报渠道的阶梯比较 + 版本号里的预发布阶段。清单可能写错, 版本号不会,所以正式版渠道即使拿到一份自称 stable 的 rc 也不会提示更新。 - 校验两道判据且顺序有意:先比长度(抓截断,不必读全文件)再比摘要(抓内容错), 长度要求**恰好**相等;失败即删暂存文件。StagedPackage 只能由 stage() 产出, 于是「未校验的包」在类型上就进不了安装路径。 - 替换跨平台统一成「旧文件改名挪开 → 新文件搬进来 → 失败把备份搬回去」:Windows 上 覆盖运行中的 exe 不存在,改名是唯一可用动作,而改名也可能被不共享删除的句柄拒绝, 所以失败必须能回滚。Unix 也照这个顺序跑,避免回滚路径只有 Windows 被真正跑过。 - core 零 UI 依赖不变:只有 serde/serde_json + 复用的 sha1/sha2,无新依赖, Cargo.toml 未改;不接 presenter、不接 UI。 单测 62 条(crates/core/src/update/tests/):版本边界、清单校验、渠道准入、 摘要不符、包体截断(短了/多了)、无权限写目标目录、Windows 占用文件、回滚与备份清理。
This commit is contained in:
1 parent
156abfe38b
commit
2e718af711
21 files changed
+3309
No files matched your search
@@ -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<StagedPackage, UpdateError> {
|
||||
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<u64, UpdateError> {
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user