Files
Unlucky-Launcher/crates/cli/tests/update_e2e_tamper_test.rs
T
worker-updatee2e 24826c1a7c feat(m169): 更新通道端到端验收 —— 本地源改成编译期 feature,补一条可一键跑的 E2E + CI 守门
§6 欠账:更新链路的 GUI 端从来没验过,而 M167 发现它根本没法在本机验——`ULL_UPDATE_DIR` 只在
`debug_assertions` 下生效,发布产物却是 release;`apply` 又会替换本进程 exe,于是没人敢跑。

判据换档(tower 定案 B):`cfg!(debug_assertions)` → 编译期 cargo feature `update-test-source`
(core 定义,cli/presenter/app-iced 链式转发,**默认关**)。带 feature 的构建里还要两档环境变量
(`ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1`),只给路径什么都不会发生。官方发布二进制里那段
分支根本编不进来——README「release builds do not contain that path at all」这句因此继续为真。

- core: `job::default_source` 的本地源分支整段 `#[cfg(feature = "update-test-source")]`;判据抽成
  纯函数 `test_source_root_from` 并逐条单测(半开一律关、空路径、承认值只认 `1`);
  `the_test_channel_is_a_compile_time_opt_in` 在**两种构建下各断各的**(带 feature ⇒ 换源;
  不带 ⇒ 环境变量设满也走 GitHub)。
- cli: `update check/apply` 的 `--json` 增加 `test_source` 字段(判据派生自实际用了哪个源,不是回读
  环境变量),人读输出多一行点名;`help.rs` 的 `ULL_UPDATE_DIR` 说明按新判据重写。
- 验收:`crates/cli/tests/update_e2e_test.rs` + `update_e2e_tamper_test.rs` + 共用夹具
  `tests/support/update_e2e.rs`。临时数据根 + 本地源 + 真形状清单(与 `manifest.golden.json` 同
  字段)+ 真 PE 产物(现场构建的 ull.exe 前 1 MiB,理由写在 `PAYLOAD_LIMIT`)。断言覆盖:发现
  新版本(source=file:… / test_source=true / size / notes)、下载 → 双检 → 就位(dest 字节、旧字节
  进备份、暂存无残留)、重复 apply 的幂等终态、篡改 sha256(清单撒谎 / 包体被换)与截断都必须
  拒绝且不动现有安装、修复后能重装。**永远带 `--dest`**:不带会真的改名换掉测试二进制。
- 守门:`packaging/check_test_source_off.py`(stdlib-only)扫 `.github/workflows/**`、
  `packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`,任何一处打开 feature(或 `--all-features`)
  就红;`--self-test` 注入 5 条真实回归证明每条都会红。CI 挂在 release-guard.yml 两步。

验证(本机):core update 单测 80 绿 / 带 feature 6 绿;E2E debug 6 绿;
`cargo test --release --features update-test-source` 6 绿(tower 要求 release 档验收);
`cargo test -p unlucky-cli -p unlucky-presenter` 全绿;守门 self-test + 正式扫描绿(真注入后红)。

已知没验:真 GitHub Release 下载、GUI 点击与重启替换(人工);跨卷替换(另开 finding)。
2026-11-08 11:12:10 +08:00

104 lines
4.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 更新通道端到端验收(M169)的**拒绝侧**:篡改摘要、篡改包体、截断包体。
//!
//! 正路与幂等在 `update_e2e_test.rs`(现场说明、抓得住/抓不住、不验的部分都在那边,别重复读)。
//! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到
//! 「修好清单也装不上」。
//!
//! 一键跑(必须带 feature;tower 定案:验收落在 release profile 上):
//!
//! ```text
//! cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_tamper_test
//! ```
//!
//! 不带 feature 时整个目标 `#![cfg]` 掉(0 个用例)——测试通道在那种构建里不存在。
#![cfg(feature = "update-test-source")]
#[path = "support/mod.rs"]
mod support;
use support::update_e2e::{channel_lock, sha256, Channel, Fixture, INSTALLED};
/// 清单的 `sha256` 与盘上字节不符 ⇒ 拒绝,且**现有安装一个字节都不动**。
///
/// 抓得住:校验和判据被绕过(写成恒真、先搬后校、只比长度)。抓不住:清单与包体一起被换
/// (同时改 sha),那条路上没有可信根。
#[test]
fn a_tampered_digest_is_refused_and_leaves_the_install_intact() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-digest");
Channel::enable(fx.dist.path());
// 长度对、摘要错:先过长度判据,才能测到摘要这一关。
fx.publish(&sha256(b"another build entirely"), fx.payload.len() as u64);
let (code, json) = fx.apply();
assert_ne!(code, 0, "摘要不符必须拒绝(实际退出码 0):{json}");
assert_eq!(json["error"]["code"], "update_apply_failed", "{json}");
assert_eq!(json["error"]["detail"]["kind"], "digest_mismatch", "{json}");
assert_eq!(fx.installed_bytes(), INSTALLED, "校验失败不许动目标文件");
assert!(
fx.backups().is_empty(),
"还没走到替换就失败了,安装目录里不该出现 .ullold:{:?}",
fx.backups()
);
assert!(
fx.staging().exists(),
"下载过才会建暂存目录(说明失败点在双检,不是根本没下)"
);
assert!(
fx.staging_leftovers().is_empty(),
"校验失败的 .ullupd 必须被删掉:{:?}",
fx.staging_leftovers()
);
// 修好清单再装一次:失败没把现场搞脏,这条路还能走通。
fx.publish_good();
let (code, json) = fx.apply();
assert_eq!(code, 0, "修好清单之后应能装上:{json}");
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "installed", "{json}");
assert_eq!(fx.installed_bytes(), fx.payload);
}
/// 产物被换(清单没动)⇒ 一样拒绝:篡改落在哪一侧都能抓到。
#[test]
fn a_tampered_artifact_is_refused_too() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-artifact");
Channel::enable(fx.dist.path());
fx.publish_good();
// 盘上换成同长度的另一份字节:只有摘要能抓(长度判据看不出区别)。
let mut evil = fx.payload.clone();
evil[0] ^= 0xff;
std::fs::write(fx.dist.path().join(&fx.asset), &evil).expect("篡改产物");
let (code, json) = fx.apply();
assert_ne!(code, 0, "篡改的包必须拒绝:{json}");
assert_eq!(
json["error"]["detail"]["kind"], "digest_mismatch",
"篡改产物应当报摘要不符:{json}"
);
assert_eq!(fx.installed_bytes(), INSTALLED);
assert!(fx.staging_leftovers().is_empty());
}
/// 包体被截断(清单长度按完整包算)⇒ 长度判据先拒:不读摘要、不碰目标。
#[test]
fn a_truncated_payload_is_refused_as_size_mismatch() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-truncated");
Channel::enable(fx.dist.path());
fx.publish_good();
let full = fx.payload.clone();
std::fs::write(fx.dist.path().join(&fx.asset), &full[..full.len() / 2]).expect("截断产物");
let (code, json) = fx.apply();
assert_ne!(code, 0, "截断的包必须拒绝:{json}");
assert_eq!(json["error"]["detail"]["kind"], "size_mismatch", "{json}");
assert_eq!(fx.installed_bytes(), INSTALLED);
}