fix(app): OTA 信任链加固、引擎背压与协议正确性
OTA(update/):
- 解压前校验 7z 条目清单:sevenz-rust 0.6.1 的 decompress_file 直接 dest.join(entry.name()),不校验 ".."、绝对路径与盘符,恶意归档可越界写文件;现在要求恰好一个条目且名字与约定二进制一致(fail-closed)
- 无 .sha256 侧车时拒绝安装,不再静默跳过校验而 UI 仍宣称已校验
- 安装前重算暂存文件哈希;同意标记从仅版本号改为绑定「版本 + 哈希」
- 跨卷 rename 失败回退「复制 + fsync + 删源」(暂存在 APPDATA、exe 在安装目录,不同卷时必失败且报错误导)
- .old 回滚备份保留 7 天:原实现新版本首启即删,新版本一旦崩溃用户无任何回退路径
- 新增安装互斥锁:两实例同时安装会互删暂存包、争抢同一个 .old
- platform_triple 用 std::env::consts::ARCH:写死 x86_64 让 ARM 客户端永远挑不到产物
- pick_assets 按命名约定精确后缀匹配:非 Windows 下 ends_with("") 恒真,.tar.gz/.deb 会被当成可执行文件下载并替换自身
引擎(mqtt/):
- 事件通道改有界并分级:高频事件(消息/日志)满即丢并计数、每 1000 条提示一次;状态/订阅结果/发布回执异步补投。原无界通道在到达速率超过消费速率时内存无上界(单条最长 128KB)
- 重连改指数退避(1s→30s 封顶)+ 0~500ms 抖动,替代固定 2 秒的惊群式重试
- 测试连接的建连(读证书,阻塞 IO)移入 spawn_blocking,与 connect 一致
正确性与性能:
- session_params_changed 补齐 4 个 v5 CONNECT 属性(会话过期/接收上限/最大报文/主题别名),漏掉会让"重连后生效"提示静默失效;测试改逐字段防漏式断言
- ConnectionConfig / AliyunPreset 手写脱敏 Debug:password 与 AccessKeySecret/DeviceSecret 输出 <redacted>,杜绝一次 {:?} 把凭据送进 stderr 与 Sentry
- MqttRecord.user_properties 改 Arc<[(String,String)]>、应用日志改 Arc<LogEntry>:消息面板每帧 300 条、日志面板每帧 3000 条的深拷贝降为引用计数
- 阿里云 DeviceName 空值校验,避免拼出非法 clientId 后服务端报错难懂
This commit is contained in:
+225
-25
@@ -1,7 +1,7 @@
|
||||
//! 更新执行:下载暂存(7z 优先)→ sha256 校验 → 重启自替换安装,以及同意标记与旧档清理。
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
use super::check::agent;
|
||||
@@ -55,6 +55,14 @@ pub fn download_and_stage(
|
||||
|
||||
// 清掉其他版本的残留暂存文件,避免堆积与误装旧版
|
||||
let staged_path = if is_7z {
|
||||
// 安全前置校验:sevenz-rust 0.6.1 的 decompress_file 直接把 entry.name()
|
||||
// 拼到目标目录(de_funcs.rs: dest.join(entry.name())),不校验 ".."、
|
||||
// 绝对路径与盘符,还会为条目创建父目录,恶意归档足以越界写文件。
|
||||
// 打包端约定归档内只有 binary_name 一个条目,因此这里要求完全一致。
|
||||
if let Err(e) = verify_archive_entries(&tmp, &binary_name) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
// 解压到暂存目录,取出二进制后删掉压缩包
|
||||
if let Err(e) = sevenz_rust::decompress_file(&tmp, &dir) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
@@ -76,12 +84,157 @@ pub fn download_and_stage(
|
||||
std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?;
|
||||
dst
|
||||
};
|
||||
// 非 Windows 平台补可执行位:File::create 落盘默认 0644,rename 保留权限,
|
||||
// 直接 spawn 会 EACCES —— 这正是 Linux/macOS 自更新必定失败的原因。
|
||||
if let Err(e) = make_executable(&staged_path) {
|
||||
let _ = std::fs::remove_file(&staged_path);
|
||||
return Err(e);
|
||||
}
|
||||
// 记录最终二进制的哈希:安装同意标记与安装前复核都绑定它
|
||||
let sha256 = file_sha256(&staged_path)?;
|
||||
Ok(StagedUpdate {
|
||||
version: info.version.clone(),
|
||||
path: staged_path,
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
|
||||
/// 计算文件的 sha256(小写 hex)。
|
||||
fn file_sha256(path: &Path) -> Result<String, String> {
|
||||
use sha2::Digest as _;
|
||||
let mut file =
|
||||
std::fs::File::open(path).map_err(|e| format!("打开 {} 失败: {e}", path.display()))?;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = file
|
||||
.read(&mut buf)
|
||||
.map_err(|e| format!("读取 {} 失败: {e}", path.display()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
/// 校验 7z 归档的条目清单(只读归档头,不解压数据)。
|
||||
///
|
||||
/// 依赖 sevenz-rust 0.6.1 的 `decompress_file` 会把 `entry.name()` 直接拼到目标目录
|
||||
/// (`de_funcs.rs: dest.join(entry.name())`),既不校验 `..`、绝对路径与盘符,还会为
|
||||
/// 条目自动创建父目录——带恶意条目的压缩包足以写出暂存目录之外(如启动目录),
|
||||
/// 随后被 `install_staged` 执行。这里在解压**之前**按归档头校验条目:必须恰好一个,
|
||||
/// 且名字与约定文件名完全一致,任何偏差都拒绝(fail-closed)。
|
||||
fn verify_archive_entries(archive: &Path, expected: &str) -> Result<(), String> {
|
||||
let file = std::fs::File::open(archive).map_err(|e| format!("打开更新包失败: {e}"))?;
|
||||
let len = file
|
||||
.metadata()
|
||||
.map_err(|e| format!("读取更新包大小失败: {e}"))?
|
||||
.len();
|
||||
let reader = sevenz_rust::SevenZReader::new(file, len, sevenz_rust::Password::empty())
|
||||
.map_err(|e| format!("解析更新包失败: {e}"))?;
|
||||
let files = &reader.archive().files;
|
||||
if files.len() != 1 {
|
||||
return Err(format!(
|
||||
"更新包应恰好包含 1 个条目 '{expected}',实际 {} 个",
|
||||
files.len()
|
||||
));
|
||||
}
|
||||
let entry = &files[0];
|
||||
let name = entry.name();
|
||||
if entry.is_directory() || name != expected || name.contains('/') || name.contains('\\') {
|
||||
return Err(format!(
|
||||
"更新包内容异常:条目 '{name}' 不符合约定(期望仅 '{expected}')"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unix 下补可执行位(Windows 无此概念,空实现)。
|
||||
#[cfg(unix)]
|
||||
fn make_executable(path: &Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mode = std::fs::metadata(path)
|
||||
.map_err(|e| format!("读取文件权限失败: {e}"))?
|
||||
.permissions()
|
||||
.mode();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode | 0o755))
|
||||
.map_err(|e| format!("设置可执行权限失败: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn make_executable(_path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把新版本文件放到目标位置。
|
||||
///
|
||||
/// 暂存在数据目录(`%APPDATA%` / `~/.local/share`),可执行文件在安装目录,二者可能
|
||||
/// 不同卷:Windows 的 `MoveFile` 与 Unix 的 `rename` 跨卷都会失败,因此失败时回退为
|
||||
/// 「复制 + 刷盘 + 删除源文件」,而不是直接报"可能无写入权限"。
|
||||
fn place_file(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
match std::fs::rename(src, dst) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => {
|
||||
let mut input = std::fs::File::open(src)?;
|
||||
let mut output = std::fs::File::create(dst)?;
|
||||
std::io::copy(&mut input, &mut output)?;
|
||||
output.sync_all()?;
|
||||
drop(output);
|
||||
let _ = std::fs::remove_file(src);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装互斥锁:暂存目录下的 `install.lock`。
|
||||
///
|
||||
/// 用 `create_new` 原子抢占;超过 10 分钟的锁视为上次异常退出(`exit` 不跑 Drop)
|
||||
/// 留下的陈旧锁,可被接管。两个实例同时安装会互删暂存包、争抢同一个 `.old`。
|
||||
struct InstallLock {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl InstallLock {
|
||||
fn acquire() -> Result<Self, String> {
|
||||
let path = stage_dir()?.join("install.lock");
|
||||
let stale_after = std::time::Duration::from_secs(10 * 60);
|
||||
for attempt in 0..2 {
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(mut f) => {
|
||||
use std::io::Write as _;
|
||||
let _ = write!(f, "{}", std::process::id());
|
||||
return Ok(Self { path });
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
let is_stale = std::fs::metadata(&path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.is_some_and(|age| age > stale_after);
|
||||
if is_stale && attempt == 0 {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
continue;
|
||||
}
|
||||
return Err("另一个实例正在安装更新,已跳过本次安装".into());
|
||||
}
|
||||
Err(e) => return Err(format!("创建安装锁失败: {e}")),
|
||||
}
|
||||
}
|
||||
Err("创建安装锁失败".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InstallLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u64, u64)) -> Result<(), String> {
|
||||
let resp = agent()
|
||||
.get(&info.asset_url)
|
||||
@@ -115,14 +268,15 @@ fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u6
|
||||
));
|
||||
}
|
||||
|
||||
// sha256 校验
|
||||
if let Some(expect) = fetch_expected_sha256(info)? {
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if !actual.eq_ignore_ascii_case(&expect) {
|
||||
return Err(format!(
|
||||
"校验失败:期望 sha256 {expect},实际 {actual}"
|
||||
));
|
||||
}
|
||||
// sha256 校验:缺侧车不再静默跳过——一次漏传就会让所有客户端在"零完整性校验"
|
||||
// 状态下安装,而 UI 仍宣称"已通过 sha256 校验"。发布脚本两侧都会生成侧车,
|
||||
// 因此这里 fail-closed 不会误伤正常发布。
|
||||
let expect = fetch_expected_sha256(info)?.ok_or_else(|| {
|
||||
"Release 未提供 .sha256 校验文件,无法验证完整性,已拒绝安装".to_string()
|
||||
})?;
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if !actual.eq_ignore_ascii_case(&expect) {
|
||||
return Err(format!("校验失败:期望 sha256 {expect},实际 {actual}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -138,7 +292,7 @@ fn remove_other_staged(dir: &std::path::Path, keep: &std::path::Path) {
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with("mqttx-v")
|
||||
&& name.contains(platform_triple())
|
||||
&& name.contains(platform_triple().as_str())
|
||||
&& !name.ends_with(".tmp")
|
||||
&& !name.ends_with(".sha256")
|
||||
&& !name.ends_with(".7z")
|
||||
@@ -190,9 +344,14 @@ pub fn load_staged() -> Option<StagedUpdate> {
|
||||
continue;
|
||||
}
|
||||
if best.as_ref().is_none_or(|b| is_newer(&version, &b.version)) {
|
||||
// 启动扫描时重算哈希:安装同意标记绑定了它,缺了就无法判定"是否已同意"
|
||||
let Ok(sha256) = file_sha256(&e.path()) else {
|
||||
continue;
|
||||
};
|
||||
best = Some(StagedUpdate {
|
||||
version,
|
||||
path: e.path(),
|
||||
sha256,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -202,14 +361,27 @@ pub fn load_staged() -> Option<StagedUpdate> {
|
||||
/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`,
|
||||
/// 新进程启动时由 [`cleanup_old`] 清理)。成功后调用方应退出当前进程。
|
||||
pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
|
||||
// 安装前复核:从"下载校验通过"到"下次启动安装"之间,暂存文件可能被替换或损坏
|
||||
let actual = file_sha256(&staged.path)?;
|
||||
if !actual.eq_ignore_ascii_case(&staged.sha256) {
|
||||
return Err(format!(
|
||||
"暂存文件校验失败(可能被替换或损坏):期望 {},实际 {actual}",
|
||||
staged.sha256
|
||||
));
|
||||
}
|
||||
|
||||
let cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?;
|
||||
let mut old_name = cur.clone().into_os_string();
|
||||
old_name.push(".old");
|
||||
let old = PathBuf::from(old_name);
|
||||
|
||||
// 安装互斥:两个实例同时安装会互删暂存包、争抢同一个 .old
|
||||
let lock = InstallLock::acquire()?;
|
||||
|
||||
// 旧残留先清掉,避免改名失败
|
||||
let _ = std::fs::remove_file(&old);
|
||||
std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?;
|
||||
if let Err(e) = std::fs::rename(&staged.path, &cur) {
|
||||
if let Err(e) = place_file(&staged.path, &cur) {
|
||||
// 回滚,尽量保住当前可执行文件
|
||||
let rollback = std::fs::rename(&old, &cur);
|
||||
return Err(format!(
|
||||
@@ -227,6 +399,8 @@ pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
|
||||
));
|
||||
}
|
||||
clear_install_consent();
|
||||
// 显式释放:调用方随后就 exit/quit,Drop 不保证执行,残留锁会让下次安装被误判为并发
|
||||
drop(lock);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -247,10 +421,11 @@ fn consent_path() -> Result<PathBuf, String> {
|
||||
stage_dir().map(|d| d.join("install-consent.txt"))
|
||||
}
|
||||
|
||||
/// 记录对指定版本的安装同意。
|
||||
pub fn mark_install_consent(version: &str) {
|
||||
/// 记录安装同意:写入「版本 + 暂存文件 sha256」两行。
|
||||
/// 绑定哈希后,暂存文件被替换就不再被视为"用户已同意安装的那一份"。
|
||||
pub fn mark_install_consent(staged: &StagedUpdate) {
|
||||
if let Ok(p) = consent_path() {
|
||||
let _ = std::fs::write(p, version);
|
||||
let _ = std::fs::write(p, format!("{}\n{}\n", staged.version, staged.sha256));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,13 +436,18 @@ pub fn clear_install_consent() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 同意标记是否与暂存的版本匹配(版本不匹配视为未同意)。
|
||||
pub fn install_consent_matches(staged_version: &str) -> bool {
|
||||
consent_path()
|
||||
.ok()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.map(|v| v.trim() == staged_version)
|
||||
.unwrap_or(false)
|
||||
/// 同意标记是否与暂存的版本**与哈希**都匹配(任一不匹配视为未同意)。
|
||||
pub fn install_consent_matches(staged: &StagedUpdate) -> bool {
|
||||
let Some(p) = consent_path().ok() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(p) else {
|
||||
return false;
|
||||
};
|
||||
let mut lines = text.lines();
|
||||
let version_ok = lines.next().map(str::trim) == Some(staged.version.as_str());
|
||||
let sha_ok = lines.next().map(str::trim) == Some(staged.sha256.as_str());
|
||||
version_ok && sha_ok
|
||||
}
|
||||
|
||||
/// 用户拒绝本次更新:删除暂存包与同意标记。
|
||||
@@ -276,22 +456,42 @@ pub fn discard_staged(staged: &StagedUpdate) {
|
||||
clear_install_consent();
|
||||
}
|
||||
|
||||
/// `.old` 回滚备份的保留时长:新版本首启不立即删除备份,
|
||||
/// 否则新版本一旦启动即崩溃,用户就再也没有自助回退的路径。
|
||||
const OLD_BACKUP_KEEP: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 3600);
|
||||
|
||||
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。
|
||||
///
|
||||
/// `.old` 是本版本唯一的回滚备份:**不能一启动就删**。策略是保留最近一份直到超过
|
||||
/// `OLD_BACKUP_KEEP`,更早的备份立即清理。
|
||||
pub fn cleanup_old() {
|
||||
if let Ok(cur) = std::env::current_exe()
|
||||
&& let Some(dir) = cur.parent()
|
||||
&& let Ok(entries) = std::fs::read_dir(dir)
|
||||
{
|
||||
let mut backups: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
// Windows 文件系统不区分大小写,统一按小写匹配
|
||||
let name = e.file_name();
|
||||
let lower = name.to_string_lossy().to_lowercase();
|
||||
if (lower.ends_with(".old") && lower.starts_with("mqttx"))
|
||||
|| lower.ends_with(".update.tmp")
|
||||
{
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
if lower.ends_with(".old") && lower.starts_with("mqttx") {
|
||||
let modified = e
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
backups.push((modified, e.path()));
|
||||
}
|
||||
}
|
||||
// 新的在前
|
||||
backups.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||
for (i, (modified, path)) in backups.iter().enumerate() {
|
||||
let age = modified.elapsed().unwrap_or_default();
|
||||
// 保留最近一份作为回滚备份,直到超过保留期;其余立即清理
|
||||
if i == 0 && age < OLD_BACKUP_KEEP {
|
||||
continue;
|
||||
}
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
// 暂存目录里中断的 .tmp。跳过最近修改的文件,避免误删
|
||||
// 并行实例正在写入的临时文件。
|
||||
|
||||
Reference in New Issue
Block a user