更新源还没拍板,所以先把与源无关的那半边做掉:语义化版本比较、发布清单模型、 渠道准入、下载校验、原子替换 + 回滚。源抽象成 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 占用文件、回滚与备份清理。
135 lines
4.4 KiB
Rust
135 lines
4.4 KiB
Rust
//! 下载产物的校验:长度(截断)+ 摘要(内容),不符即拒绝安装。
|
||
//!
|
||
//! 承 `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)
|
||
}
|