Files
Unlucky-Launcher/crates/cli/tests/update_e2e_tamper_test.rs
T
worker-updatee2e 28bf104041 fix(m169): 复审 r1 五条 p2 —— 守门开一个极窄 allowlist 并把 E2E 真接进 CI
r1 复审(reviewer-updatee2e-r1,p2-5items,均不挡合并)逐条落地:

P2-3(本轮主产出)守门把「E2E 进 CI」这条路堵死了:`scan_enabling_text` 对 workflow 里任何该
feature 字面量判红,于是 `cargo test --release … --features update-test-source --test
update_e2e_test` 这种**合法调用**也红,自己登记的 follow-up 无法实现。现在:
- `ALLOWED_STEP = {workflow: .github/workflows/ci.yml, name: update-e2e,
  must_contain: --test update_e2e_test}`;按 `- name:` 把 workflow 切成步骤块再判,文件名 / 步骤名
  / 命令行**三者任一不符即红**(job 名不参与判据:改 job 名不该解锁新能力)。
- `ci.yml` **新增一个独立 job** `update-e2e`(windows-latest、timeout 45m、release 档;现有 job
  一行未动,纯追加 27 行)。只在 Windows:夹具的「真形状」是裸 `.exe` + `portable-exe`,Linux/macOS
  的真实产物是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable` 直接拒),在 ubuntu 上跑绿
  是假绿。不降 debug:`debug_assertions`/优化/panic 行为都不同。
- self-test 覆盖 allowlist 六向量(允许 / 换文件 / 换步骤名 / 缺 `--test` / 发布工作流 / ci 内另一步)。

P2-2 守门 8 条绕过:注释剥离改**引号感知**(`echo "#"; cargo …` 不再吃掉真命令);扫描面扩到
`.github/actions/**`、`packaging/**` 递归子目录、`Makefile|justfile|Taskfile` 第二构建入口;
`--all-features` 之外补 `cargo hack --feature-powerset|--each-feature|--all`。大小写/下划线变体按
复审结论写进「抓不住」表(cargo feature 名敏感,写错编不过,不算漏检)。

P2-1 GUI 人工验收命令根本跑不起来:`-p unlucky-launcher-iced` 是 bin 名,改成 package 名
`-p unlucky-app-iced`(app-iced / presenter 两处,并写明 `-p` 只认 package)。

P2-5 夹具缺「不给 --dest 就 panic」的结构性保险:`Fixture::run` 加两条断言——`apply` 必带
`--dest`、`--dest` 不许等于本进程 exe 的 canonical 路径。抓得住「以后有人手写 fx.run(&["apply"])」,
抓不住「绕开 Fixture 直接调 unlucky_cli::run」。

P2-4 夹具「真形状」只在 Windows 成立:写进 `tests/support/update_e2e.rs` 文件头(说明 Linux 上跑绿
是假绿、CI 因此只在 Windows),**不改断言**。另按复审 ⑤ 补一行:失败路径三例抓不住回滚/备份
(摘要失败发生在 install_staged 之前),回滚靠正路备份断言 + install_cases。

跨卷替换 bug 按 tower 指令**未动**(另开 mission)。

验证:守门正式扫描 + self-test 绿(14 红 / 3 绿);真仓注入 6 组(基线绿、步骤改名红、缺 --test 红、
搬进 release-build 红、改 --all-features 红、还原绿);E2E debug 6 绿、**release 6 绿**;
`cargo clippy -p unlucky-cli --all-targets --features update-test-source -- -D warnings` 干净;
`cargo fmt --all -- --check` 干净;全仓 400 行扫描无超限(守门脚本 379 行、ci.yml 317 行)。

⚠ 未验:CI 里 E2E 真的跑起来这件事本机验不了——只证明 workflow 写对 + 守门放行,首次真实运行要等
推送后看 Actions。
2026-11-08 11:12:11 +08:00

108 lines
4.6 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`(现场说明、抓得住/抓不住、不验的部分都在那边,别重复读)。
//! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到
//! 「修好清单也装不上」。
//!
//! ⚠ 这三例**抓不住回滚/备份**:摘要与长度失败都发生在 `install_staged` 之前,那一步走不到
//! (r1 复审 ⑤ 的变异实测:去掉 dest 备份后这三例仍全绿)。回滚由 `update_e2e_test.rs` 的备份
//! 断言与 `core::update::tests::install_cases` 守。
//!
//! 一键跑(必须带 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);
}