From 28bf1040417bdd081e28c76c8bc091f1a87d9d9e Mon Sep 17 00:00:00 2001 From: worker-updatee2e Date: Sun, 8 Nov 2026 10:32:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(m169):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E4=BA=94=E6=9D=A1=20p2=20=E2=80=94=E2=80=94=20=E5=AE=88?= =?UTF-8?q?=E9=97=A8=E5=BC=80=E4=B8=80=E4=B8=AA=E6=9E=81=E7=AA=84=20allowl?= =?UTF-8?q?ist=20=E5=B9=B6=E6=8A=8A=20E2E=20=E7=9C=9F=E6=8E=A5=E8=BF=9B=20?= =?UTF-8?q?CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- .github/workflows/ci.yml | 27 ++ .github/workflows/release-guard.yml | 7 +- crates/app-iced/Cargo.toml | 4 +- crates/cli/tests/support/update_e2e.rs | 34 ++ crates/cli/tests/update_e2e_tamper_test.rs | 4 + crates/cli/tests/update_e2e_test.rs | 1 + crates/presenter/Cargo.toml | 5 +- packaging/README.md | 13 +- packaging/check_test_source_off.py | 388 +++++++++++++++------ 9 files changed, 359 insertions(+), 124 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c04f1b9..93d7106 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,3 +288,30 @@ jobs: name: runtime-smoke-screenshot-macos path: ${{ runner.temp }}/smoke/ if-no-files-found: warn + + # M169:更新链路的**端到端验收**(发现新版本 → 下载 → 长度/SHA256 双检 → 就位;篡改摘要 / + # 篡改包体 / 截断必须拒绝且不动现有安装;重复 apply 的幂等终态)。 + # + # 独立 job、只在 Windows:夹具的「真形状」只在 Windows 成立——真实产物是裸 `.exe` + + # `portable-exe`,而 Linux/macOS 是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable` + # 直接拒)。在 ubuntu 上跑绿只能证明「一个世上不存在的 linux portable-exe 能装上」,是假绿 + # (r1 复审 P2-4)。 + # + # 本 job 的步骤名 `update-e2e` 是**唯一**被允许出现 `update-test-source` 的地方,判据在 + # `packaging/check_test_source_off.py` 的 `ALLOWED_STEP`:文件名 / 步骤名 / `--test + # update_e2e_test` 三者任一不符即红——改这里的 name 或 run 之前先读那段注释。 + # + # 慢是已知的(release + fat LTO,本机实测 7 分钟;不发 debug 档:`debug_assertions`、优化与 + # panic 行为都不一样,用 debug 跑这条 E2E 就失去了它存在的理由)。tower 明确:能跑起来比跑得快重要。 + update-e2e: + name: update-e2e (windows x64) + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: update-e2e + run: > + cargo test --release -p unlucky-cli --features update-test-source + --test update_e2e_test --test update_e2e_tamper_test diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml index 39d99be..b1891ca 100644 --- a/.github/workflows/release-guard.yml +++ b/.github/workflows/release-guard.yml @@ -26,8 +26,11 @@ name: Release guard # (M169)同一个 job 再挂**测试通道守门** `packaging/check_test_source_off.py`:core/cli/presenter/ # app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被 # 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物 -# 里不许有它:守门扫 `.github/workflows/**`、`packaging/*.py|*.sh` 与各 `Cargo.toml`(`default` -# 里带上它也红,那种开法扫 workflow 是看不见的)。`--self-test` 注入 5 条真实回归证明每条都会红。 +# 里不许有它:守门扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、 +# `Makefile`/`justfile`/`Taskfile` 与各 `Cargo.toml`(`default` 里带上它也红,那种开法扫 workflow +# 是看不见的)。**唯一允许**是 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test +# update_e2e_test`,文件名/步骤名/参数任一不符即红)。`--self-test` 注入 14 条真实回归(allowlist +# 六向量 + 七条绕过 + Cargo 两条),该红的红、该绿的绿。 on: push: diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index 7fed171..248cecb 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -92,7 +92,9 @@ default = ["gpu"] gpu = ["iced/wgpu"] # M169:把「本地更新源」这条测试通道编进 GUI(默认关)。人工验收更新链路时的构建方式: # ULL_UPDATE_DIR= ULL_UPDATE_TEST_SOURCE=1 \ -# cargo run -p unlucky-launcher-iced --features update-test-source +# cargo run -p unlucky-app-iced --features update-test-source +# ⚠ `-p` 只认 **package** 名(`unlucky-app-iced`),`unlucky-launcher-iced` 是它在 [[bin]] 里 +# 声明的 bin 名——写成 `-p unlucky-launcher-iced` 会 `package(s) … not found`(r1 复审 P2-1)。 # 转发给 presenter 与内嵌的 cli(两者都依赖 core,feature 取并集);官方发布构建不带它。 update-test-source = ["unlucky-presenter/update-test-source", "unlucky-cli/update-test-source"] diff --git a/crates/cli/tests/support/update_e2e.rs b/crates/cli/tests/support/update_e2e.rs index b4d9ce3..d378b7d 100644 --- a/crates/cli/tests/support/update_e2e.rs +++ b/crates/cli/tests/support/update_e2e.rs @@ -6,9 +6,23 @@ //! 整个夹具只在 `update-test-source` feature 下真的被用到:那两个测试目标自己 `#![cfg]` 掉, //! 这里只是跟着编译(不跑)。判据(feature + 两档环境变量)在 `core::update::job`。 //! +//! # 为什么这套「真形状」只在 Windows 上成立(r1 复审 P2-4) +//! +//! 产物名与形态按 **Windows** 的真实发布走:`unlucky-launcher__windows_x64.exe` + +//! `portable-exe`(口径见 `packaging/make_artifacts.py` 的头注释)。Linux/macOS 的真实产物是 +//! `.tar.gz` + `portable-zip`,会被 `ensure_self_replaceable` **直接拒**(不是可原地自替换的形态)。 +//! +//! * 在 Windows 上跑 = 验真实形状; +//! * 在别的平台上跑绿只能证明「一个世上不存在的 linux/macOS portable-exe 能装上」——**假绿**。 +//! +//! 所以 CI 里它是**一个独立的 Windows job**(`.github/workflows/ci.yml` 的 `update-e2e`, +//! `runs-on: windows-latest`),本文件也**不**为非 Windows 平台改断言:要真支持,得让产物名与 +//! `format` 一起按平台分叉,那是另一个 mission 的事(真实 Linux 自更新根本不支持,D50 只发裸 exe)。 +//! //! ⚠ **验收必须给 `--dest`**:用例在**进程内**跑 `unlucky_cli::run`,`Job::for_process` 的 //! 「本进程的 exe」就是测试二进制——`apply` 不带 `--dest` 会真的把它 `rename` 成新字节 //! (Windows 允许改名正在运行的映像,见 `core::update::install` 的文档),整轮测试当场崩。 +//! `Fixture::run` 里有结构性断言兜这一条(r1 复审 P2-5),不是只靠这段注释。 #![allow(dead_code)] use std::path::{Path, PathBuf}; @@ -240,7 +254,27 @@ impl Fixture { } /// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。 + /// + /// **结构性保险(r1 复审 P2-5)**:`apply` 必须显式带 `--dest`,且 `--dest` 不许是本进程的 exe。 + /// 不带 `--dest` 就是 `Job::for_process` 的「本进程 exe」——在这套用例里那是**测试二进制**, + /// 而 Windows 允许改名正在运行的映像,于是整轮测试会把它换成一个假启动器(症状极难查)。 + /// + /// 抓得住:以后有人手写 `fx.run(&["apply", "--json"])`(漏 `--dest`),或把 dest 指到 + /// `current_exe()`——**当场 panic**,现场不会被破坏。 + /// 抓不住:绕开 `Fixture` 直接调 `unlucky_cli::run`(那等于另写一套夹具,`--data-dir` 也没人拼了)。 pub fn run(&self, args: &[&str]) -> (i32, String, String) { + if args.first() == Some(&"apply") { + assert!( + args.contains(&"--dest"), + "apply 必须带 --dest:不带会替换本进程 exe(这套用例里就是测试二进制)。\ + 唯一的 apply 入口是 `Fixture::apply()`" + ); + } + let dest = std::fs::canonicalize(&self.dest) + .expect("dest 必须存在(Fixture::new 写入了 INSTALLED)"); + if let Ok(exe) = std::env::current_exe().and_then(std::fs::canonicalize) { + assert_ne!(dest, exe, "替换目标不许是本进程的 exe"); + } let mut full = vec!["update"]; full.extend_from_slice(args); let data_dir = self.data.arg(); diff --git a/crates/cli/tests/update_e2e_tamper_test.rs b/crates/cli/tests/update_e2e_tamper_test.rs index 7cb0cac..2fb8091 100644 --- a/crates/cli/tests/update_e2e_tamper_test.rs +++ b/crates/cli/tests/update_e2e_tamper_test.rs @@ -4,6 +4,10 @@ //! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到 //! 「修好清单也装不上」。 //! +//! ⚠ 这三例**抓不住回滚/备份**:摘要与长度失败都发生在 `install_staged` 之前,那一步走不到 +//! (r1 复审 ⑤ 的变异实测:去掉 dest 备份后这三例仍全绿)。回滚由 `update_e2e_test.rs` 的备份 +//! 断言与 `core::update::tests::install_cases` 守。 +//! //! 一键跑(必须带 feature;tower 定案:验收落在 release profile 上): //! //! ```text diff --git a/crates/cli/tests/update_e2e_test.rs b/crates/cli/tests/update_e2e_test.rs index 1bcca93..b0c28db 100644 --- a/crates/cli/tests/update_e2e_test.rs +++ b/crates/cli/tests/update_e2e_test.rs @@ -35,6 +35,7 @@ //! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 | //! | 重复 `apply` 后目标字节稳定、暂存无残留 | 「第二次把 dest 写坏 / 暂存留下垃圾」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe | //! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷(数据根与 exe 不同盘)时的替换:`rename` 不能跨卷,那条路没验 | +//! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) | //! //! # 不验的部分(如实划界) //! diff --git a/crates/presenter/Cargo.toml b/crates/presenter/Cargo.toml index abeb907..55d6914 100644 --- a/crates/presenter/Cargo.toml +++ b/crates/presenter/Cargo.toml @@ -29,7 +29,8 @@ workspace = true [features] # M169:转发 core 的测试通道(默认关)。GUI 侧「更新」这条链的**人工验收**需要它: -# `cargo run -p unlucky-launcher-iced --features update-test-source` 配 -# `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 才能让设置页指向本地更新源。 +# `cargo run -p unlucky-app-iced --features update-test-source` 配 +# `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 才能让设置页指向本地更新源 +# (`-p` 认 package 名 `unlucky-app-iced`;`unlucky-launcher-iced` 是 bin 名,写 `-p` 会找不到包)。 # 官方发布构建不带这个 feature(`packaging/check_test_source_off.py` 守门)。 update-test-source = ["unlucky-core/update-test-source"] diff --git a/packaging/README.md b/packaging/README.md index 2545579..ed651f5 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -121,8 +121,13 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) - **验收构建**:`cargo build --release --features update-test-source`,再配 `ULL_UPDATE_DIR=` + `ULL_UPDATE_TEST_SOURCE=1`(两档缺一不可)。`ull update check --json` 会 如实写 `data.source: "file:"`、`data.test_source: true`,人读输出另有一行点名。 -- **守门**:`packaging/check_test_source_off.py`(CI:`.github/workflows/release-guard.yml`)扫 - `.github/workflows/**`、`packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`——任何一处打开它就红。 +- **守门**:`packaging/check_test_source_off.py`(CI:`.github/workflows/release-guard.yml`,两步: + `--self-test` + 正式扫描)扫 `.github/workflows/**`(含复合 action)、`packaging/**` 的递归脚本、 + `Makefile`/`justfile`/`Taskfile` 与各 `Cargo.toml` 的 `default`。**唯一允许**出现该 feature 的地方是 + `.github/workflows/ci.yml` 里名为 `update-e2e` 的那一步,且该步必须带 `--test update_e2e_test` + (文件名 / 步骤名 / 参数任一不符即红,见脚本的 `ALLOWED_STEP`)。 - 验收本体:`cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test - --test update_e2e_tamper_test`(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。**不验**真 GitHub 下载、 - GUI 点击与重启替换(需人工)。 + --test update_e2e_tamper_test`(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。CI 里同一个命令挂在 + `ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` + + `portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。 + **不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。 diff --git a/packaging/check_test_source_off.py b/packaging/check_test_source_off.py index 70900b1..6812e49 100644 --- a/packaging/check_test_source_off.py +++ b/packaging/check_test_source_off.py @@ -1,32 +1,41 @@ #!/usr/bin/env python3 -"""测试通道守门(M169):**官方发布产物里不许有 `update-test-source` feature**。 +"""测试通道守门(M169,r2 复审后重写):**官方发布产物里不许有 `update-test-source` feature**。 -为什么需要一条独立的守门:那条 feature 让 `core::update::job::default_source` 可以被两个环境变量 -指向任意本地目录,而更新链路的终点是**替换用户盘上的 exe**。默认关 + 编译期不存在是唯一可靠的 -边界,所以「谁也没在流水线里把它打开」必须是自动断言,不能靠注释和记性。 +那条 feature 让 `core::update::job::default_source` 可以被两个环境变量指向任意本地目录,而更新链路 +的终点是**替换用户盘上的 exe**。默认关 + 编译期不存在是唯一可靠的边界,所以「谁也没在流水线里把它 +打开」必须是自动断言,不能靠注释和记性。 -三条断言,任何一条不成立即 exit 1(CI 里红在 `.github/workflows/release-guard.yml`): +四条断言,任何一条不成立即 exit 1(CI 里红在 `.github/workflows/release-guard.yml`): -1. **工作流与打包脚本不许打开它**:`.github/workflows/*.yml|*.yaml` 与 `packaging/*.py|*.sh` 的 - 可执行内容里不许出现 `update-test-source` 字面量(本文件自己除外——它当然得写这个名字)。 - 注释里提它不算(`#` 之后一律剥掉),否则「这里不许加 X」这种说明反而会被守门判红。 -2. **不许 `--all-features`**:它会把**所有** feature 打开,包括测试通道。两个目录里出现即红。 -3. **不许设成默认**:任何 `Cargo.toml` 里 `update-test-source` 只许作为**自己的声明** - (`update-test-source = [...]`)出现;出现在 `default = [...]` 或别的 feature 的依赖数组里即红。 - 这一条专门兜「第 1 条扫不到」的那种开法:feature 一旦进 default,任何 `cargo build` 都是验收构建。 +1. **workflow 与复合 action 里不许打开它**——唯一例外是 `.github/workflows/ci.yml` 里名为 + `update-e2e` 的那一步,且该步必须同时带 `--test update_e2e_test`(见 `ALLOWED_STEP`)。 + 文件名 / 步骤名 / 命令行**三者任一不符即红**(r1 复审口径;job 名不参与判据——改 job 名不该 + 解锁新能力,能力只取决于那一行命令)。为什么要开这个口子(r1 复审 P2-3):E2E 不带 feature 时 + 两个目标是 0 例,而「任何 workflow 都不许出现这个字面量」会把唯一合法的 CI 调用点也堵死—— + 守卫强度与回归可见性会互相吃掉。口子开得极窄:一个文件、一个步骤名、一个参数。 +2. **不许「打开所有 feature」**:`--all-features` 与 cargo hack 的 `--feature-powerset` / + `--each-feature` / `--all` 都会把测试通道一起打开。 +3. **不许设成默认**:任何 `Cargo.toml` 里它只许作为**自己的声明**出现;进 `default` 或别的 + feature 的依赖数组即红(这条专治「扫 workflow 看不见」的开法)。 +4. **扫描面逐项列举**(`WORKFLOW_GLOBS` / `SCRIPT_GLOBS` / `BUILD_ENTRY_GLOBS`):含 + `.github/actions/**`、`packaging/**` 的**子目录**脚本、`Makefile`/`justfile`/`Taskfile` 这类第二 + 构建入口(r1 复审 P2-2 的漏检来源)。扩扫描面 = 往那三个元组加一行 + 补一条 self-test 向量。 -`--self-test` 是这条守门的变异自测:把每一条的**真实回归**注入临时副本(workflow 里加 -`--features update-test-source`、打包脚本里加同一个字面量、workflow 里改 `--all-features`、 -`Cargo.toml` 的 `default` 加进去),断言**每一项都红**、基线绿、撤回后重新变绿。守门自己抓不住 -回归时它会先红,所以这条断言不会变成装饰品。 +注释剥离是**引号感知**的:`- run: echo "#"; cargo build --features update-test-source` 里第一个 +`#` 在引号内,不是注释起点(粗暴 `#.*$` 会把真命令一起吃掉,也是 P2-2 漏检之一)。 # 抓得住什么 / 抓不住什么 | 抓得住 | 抓不住 | |---|---| -| 有人在 workflow / 打包脚本里加 `--features update-test-source` | 有人在自己机器上手工构建并**自己**分发(流水线管不到的范围) | -| 有人把 feature 塞进 `default`(第 3 条专治;第 1 条扫不到这种开法) | 有人改名后再接回发布:名字一变就脱离了本守门的扫描表 | -| 有人用 `--all-features` 一把梭 | `.github/actions/**` / 别的自定义 action 里间接打开:本守门只扫上面两个目录,扩扫描面时记得连它一起加 | +| workflow / 复合 action / 打包脚本 / Makefile 里打开它(含 `--features=`、引号、多行续行写法) | 变量间接引用(`--features "$FEAT"`),或 feature 名写在守门不扫的文件里 | +| `--all-features` / `cargo hack --feature-powerset|--each-feature|--all` 一把梭 | 有人把 feature 改名后再接回发布:名字一变就脱离扫描表(改名要过 review,但守门看不见) | +| 把 feature 塞进 `default` 或别的 feature 的依赖数组 | `build.rs` 里 spawn `cargo`、或第三方工具链自带的构建入口 | +| allowlist 被放宽:文件名 / 步骤名 / `--test` 参数任一不符 | allowlist 被滥用:别的步骤改名成 `update-e2e` 且带 `--test update_e2e_test` 却干别的事(扫描只看字符串;merge review 会看到 ci.yml 那一行) | +| 大小写/下划线变体不算漏检:cargo feature 名敏感,写错直接编不过(r1 复审同判) | | + +`--self-test` 是这条守门的变异自测:每条断言的**真实回归**注入临时副本,断言该红的红、该绿的绿 +(尤其 allowlist 六个向量与 P2-2 的七条绕过)。守门自己抓不住回归时它会先红。 """ from __future__ import annotations @@ -37,52 +46,169 @@ import sys import tempfile from pathlib import Path -#: 被守的 feature 名(改名就得同步改本文件与 `crates/*/Cargo.toml`)。 +#: 被守的 feature 名(改名就得同步改本文件与各 `Cargo.toml`)。 FEATURE = "update-test-source" -#: 仓库根(本文件在 `packaging/` 下)。 +#: 仓库根(本文件在 `packaging/` 下)与本文件自身(扫描时跳过:它必须写出 feature 名)。 ROOT = Path(__file__).resolve().parent.parent -#: 本文件自己(扫描时跳过:它必须写出 feature 名)。 GUARD = Path(__file__).resolve() -WORKFLOW_DIR = Path(".github/workflows") -SCRIPT_DIR = Path("packaging") -SCRIPT_SUFFIXES = (".py", ".sh") +#: 扫描面(逐个列举,不用 `**` 全仓:那会把 `target/` 拖进来)。 +WORKFLOW_GLOBS = ( + ".github/workflows/**/*.yml", + ".github/workflows/**/*.yaml", + ".github/actions/**/*.yml", + ".github/actions/**/*.yaml", +) +SCRIPT_GLOBS = ("packaging/**/*.py", "packaging/**/*.sh", "packaging/**/*.ps1") +BUILD_ENTRY_GLOBS = ( + "Makefile", + "justfile", + "Justfile", + "Taskfile.yml", + "Taskfile.yaml", + "crates/*/Makefile", + "packaging/Makefile", +) MANIFEST_GLOBS = ("Cargo.toml", "crates/*/Cargo.toml") +#: 唯一允许出现该 feature 的地方:**一个文件 + 一个步骤名 + 一个必须同时在场的参数**。 +ALLOWED_STEP = { + "workflow": ".github/workflows/ci.yml", + "name": "update-e2e", + "must_contain": "--test update_e2e_test", +} + +#: 「打开全部 feature」的开关(后三条是 cargo hack 的走法)。 +ALL_FEATURES = ( + re.compile(r"--all-features\b"), + re.compile(r"--feature-powerset\b"), + re.compile(r"--each-feature\b"), + re.compile(r"\bcargo\s+hack\b[^\n]*\s--all\b"), +) + #: Cargo.toml 里「自己的声明」长这样:行首 `update-test-source = [`。 DECLARATION = re.compile(rf"^\s*{re.escape(FEATURE)}\s*=\s*\[") -#: `#` 之后的注释(三种文件都用 `#`;fixture 里没有字符串内 `#`,够用)。 -COMMENT = re.compile(r"#.*$") -#: 会把所有 feature 打开的开关。 -ALL_FEATURES = re.compile(r"--all-features\b") +#: workflow 的一个步骤从 `- name: ` 开始。 +STEP_NAME = re.compile(r"^\s*-\s*name:\s*(.+?)\s*$") -def _stripped(line: str) -> str: - """去掉注释与行尾空白(注释里提 feature 不算违规)。""" - return COMMENT.sub("", line).strip() +def strip_comment(line: str) -> str: + """引号感知地剥注释:`#` 只有「行首或空白之后」且不在引号内时才开始注释。 + + YAML 的单引号 `''`、双引号 `\\` 转义这里不模拟——本守门只判断「引号有没有闭合」,够用, + 且不会把 `echo "#"; cargo build --features …` 当成注释。 + """ + quote = None + for index, char in enumerate(line): + if quote is not None: + if char == quote: + quote = None + elif char in "\"'": + quote = char + elif char == "#" and (index == 0 or line[index - 1].isspace()): + return line[:index] + return line -def scan_enabling_text(path: Path, root: Path) -> list[str]: - """扫「可执行内容」:命中的开关会被原样带进一次真实构建。""" +def dangerous_flag(code: str) -> str | None: + """这一行有没有「打开全部 feature」的开关(返回命中的开关,便于写清原因)。""" + for pattern in ALL_FEATURES: + match = pattern.search(code) + if match: + return match.group(0) + return None + + +def _lines(path: Path) -> list[tuple[int, str]]: + return list(enumerate(path.read_text(encoding="utf-8").splitlines(), start=1)) + + +def split_steps(lines: list[tuple[int, str]]) -> list[tuple[str | None, list[tuple[int, str]]]]: + """按 `- name: ` 把 workflow 切成「步骤名 + 该步骤的行」。 + + 没有 YAML 解析器(stdlib 不带、也不许引第三方),用「最近的 `- name:` 行」当步骤边界:GitHub + workflow 就是这个形状,而本守门只关心「某个 feature 字面量落在哪一步里」。第一个 step 之前的 + 行归到 `None` 块(含 `on:` / `env:` 这类全局段)。 + """ + blocks: list[tuple[str | None, list[tuple[int, str]]]] = [] + name: str | None = None + body: list[tuple[int, str]] = [] + for number, line in lines: + match = STEP_NAME.match(line) + if match: + if body: + blocks.append((name, body)) + name = match.group(1).strip("'\"") + body = [] + body.append((number, line)) + if body: + blocks.append((name, body)) + return blocks + + +def _allowlist_reason(where: str, name: str | None, body: list[tuple[int, str]]) -> str | None: + """`None` = 这一步被放行;否则返回「为什么红」的一句话。""" + if where != ALLOWED_STEP["workflow"]: + return ( + f"`{FEATURE}` 只许出现在 `{ALLOWED_STEP['workflow']}` 的 " + f"`{ALLOWED_STEP['name']}` 步骤里(这里是 `{where}`)" + ) + if name != ALLOWED_STEP["name"]: + return ( + f"`{where}` 里只有名为 `{ALLOWED_STEP['name']}` 的步骤可以用 `{FEATURE}`" + f"(这一步叫 `{name}`)" + ) + joined = "\n".join(strip_comment(line) for _number, line in body) + if ALLOWED_STEP["must_contain"] not in joined: + return f"`{where}` 的 `{ALLOWED_STEP['name']}` 步骤少了 `{ALLOWED_STEP['must_contain']}`" + return None + + +def scan_workflow(path: Path, root: Path) -> list[str]: + """扫 workflow / 复合 action:feature 只许出现在 allowlist 点名的那一步里。""" problems: list[str] = [] - try: - text = path.read_text(encoding="utf-8") - except OSError as error: - return [f"::error file={path}::读不到 {path}:{error}"] - for number, line in enumerate(text.splitlines(), start=1): - code = _stripped(line) + where = path.relative_to(root).as_posix() + for name, body in split_steps(_lines(path)): + code_lines = [(number, strip_comment(line)) for number, line in body] + for number, code in code_lines: + flag = dangerous_flag(code) + if flag: + problems.append( + f"::error file={where}:{number}::{where} 用了 `{flag}`:" + f"它会把 `{FEATURE}` 一起打开" + ) + hits = [(number, code) for number, code in code_lines if FEATURE in code] + if not hits: + continue + reason = _allowlist_reason(where, name, body) + if reason is None: + continue + for number, _code in hits: + problems.append( + f"::error file={where}:{number}::{reason};" + "官方发布产物里不许有测试通道(它能把更新源指向任意本地目录)" + ) + return problems + + +def scan_code_file(path: Path, root: Path) -> list[str]: + """扫脚本 / 第二构建入口:没有任何 allowlist,字面量或开关出现即红。""" + problems: list[str] = [] + where = path.relative_to(root).as_posix() + for number, line in _lines(path): + code = strip_comment(line) if not code: continue - where = path.relative_to(root).as_posix() if FEATURE in code: problems.append( f"::error file={where}:{number}::{where} 打开了 `{FEATURE}`:" "官方发布产物里不许有测试通道(它能把更新源指向任意本地目录)" ) - if ALL_FEATURES.search(code): + flag = dangerous_flag(code) + if flag: problems.append( - f"::error file={where}:{number}::{where} 用了 `--all-features`:" + f"::error file={where}:{number}::{where} 用了 `{flag}`:" f"它会把 `{FEATURE}` 一起打开" ) return problems @@ -91,15 +217,11 @@ def scan_enabling_text(path: Path, root: Path) -> list[str]: def scan_manifest(path: Path, root: Path) -> list[str]: """扫 Cargo.toml:feature 只许出现在自己的声明行上(不许进 default / 别的 feature)。""" problems: list[str] = [] - try: - text = path.read_text(encoding="utf-8") - except OSError as error: - return [f"::error file={path}::读不到 {path}:{error}"] - for number, line in enumerate(text.splitlines(), start=1): - code = _stripped(line) + where = path.relative_to(root).as_posix() + for number, line in _lines(path): + code = strip_comment(line).strip() if FEATURE not in code or DECLARATION.match(code): continue - where = path.relative_to(root).as_posix() problems.append( f"::error file={where}:{number}::{where} 把 `{FEATURE}` 写进了别的 feature 列表:" "只许它是自己的声明(`update-test-source = [...]`),不许进 `default` 或别的 feature" @@ -110,98 +232,134 @@ def scan_manifest(path: Path, root: Path) -> list[str]: def scan(root: Path) -> list[str]: """扫整个仓库,返回所有问题(空 = 绿)。""" problems: list[str] = [] - for pattern in ("*.yml", "*.yaml"): - for path in sorted((root / WORKFLOW_DIR).glob(pattern)): - problems += scan_enabling_text(path, root) - for suffix in SCRIPT_SUFFIXES: - for path in sorted((root / SCRIPT_DIR).glob(f"*{suffix}")): - if path.resolve() == GUARD: + for pattern, scanner in ( + (WORKFLOW_GLOBS, scan_workflow), + (SCRIPT_GLOBS, scan_code_file), + (BUILD_ENTRY_GLOBS, scan_code_file), + (MANIFEST_GLOBS, scan_manifest), + ): + for path in sorted({path for glob in pattern for path in root.glob(glob)}): + if scanner is scan_code_file and path.resolve() == GUARD: continue - problems += scan_enabling_text(path, root) - for pattern in MANIFEST_GLOBS: - for path in sorted(root.glob(pattern)): - problems += scan_manifest(path, root) + try: + problems += scanner(path, root) + except OSError as error: + problems.append(f"::error file={path}::读不到 {path}:{error}") return problems # ────────────────────────── 变异自测 ────────────────────────── -#: 自测基线:一份最小但结构完整的仓(路径与真实仓一致)。 +def _wf(*steps: str) -> str: + """拼一个最小 workflow(job 名与真实 `ci.yml` 一致;**job 名不参与判据**——判据锚在步骤名上, + 改 job 名不该解锁新能力。这里用真名只是让自测基线长成真文件的形状)。""" + return "jobs:\n update-e2e:\n steps:\n" + "".join(steps) + + +#: 允许的那一步(基线与若干向量共用它)。 +ALLOWED_STEP_YAML = ( + " - uses: actions/checkout@v4\n" + " - name: update-e2e\n" + " if: runner.os == 'Windows'\n" + " run: |\n" + f" cargo test --release -p unlucky-cli --features {FEATURE} \\\n" + " --test update_e2e_test --test update_e2e_tamper_test\n" +) + BASELINE: dict[str, str] = { - ".github/workflows/release.yml": "jobs:\n build:\n steps:\n - run: cargo build --release\n", + ".github/workflows/ci.yml": _wf(*[ALLOWED_STEP_YAML]), + ".github/workflows/release-build.yml": _wf(" - run: cargo build --release\n"), + ".github/workflows/release-publish.yml": _wf(" - run: echo publish\n"), "packaging/make_artifacts.py": "def build():\n return 'cargo build --release'\n", + "packaging/tests/build.sh": "#!/bin/sh\ncargo build --release\n", + "Makefile": "build:\n\tcargo build --release\n", "Cargo.toml": "[workspace]\nmembers = []\n", "crates/core/Cargo.toml": ( - "[features]\n" - f"{FEATURE} = []\n" - "# 注释里提到 update-test-source 不算违规\n" + f"[features]\n{FEATURE} = []\n# 注释里提到 update-test-source 不算违规\n" ), } -#: 每条变异 =(构造时改哪个文件、插了什么);改完必须红,撤回后必须绿。 -MUTATIONS: list[tuple[str, str, str]] = [ - ( - "workflow 里加 --features update-test-source", - ".github/workflows/release.yml", - " - run: cargo build --release --features update-test-source\n", - ), - ( - "打包脚本里加同一个 feature", - "packaging/make_artifacts.py", - "FLAGS = '--features update-test-source'\n", - ), - ( - "workflow 里改 --all-features(会一并打开测试通道)", - ".github/workflows/release.yml", - " - run: cargo build --release --all-features\n", - ), - ( - "Cargo.toml 的 default 把 feature 带上", - "crates/core/Cargo.toml", - 'default = ["update-test-source"]\n', - ), - ( - "别的 feature 依赖它", - "crates/core/Cargo.toml", - 'release = ["update-test-source"]\n', - ), +_LEAK = f" - run: cargo build --release --features {FEATURE}\n" +_HACK = " - run: cargo hack {mode} build\n" + +#: 向量 =(标签、覆盖/新增文件、期望)。期望绿的两条是**防误杀**。 +VECTORS: list[tuple[str, dict[str, str], str]] = [ + ("allowlist ①:ci.yml 的 update-e2e 一步", {}, "green"), + ("allowlist ②:同样的步骤放进 release-build.yml", + {".github/workflows/release-build.yml": _wf(*[ALLOWED_STEP_YAML])}, "red"), + ("allowlist ③:步骤名改成 update-e2e-r2", + {".github/workflows/ci.yml": _wf(ALLOWED_STEP_YAML.replace("name: update-e2e", "name: update-e2e-r2"))}, + "red"), + ("allowlist ④:少了 --test update_e2e_test", + {".github/workflows/ci.yml": _wf(ALLOWED_STEP_YAML.replace("--test update_e2e_test ", ""))}, "red"), + ("allowlist ⑤:发布工作流里另起一步带该 feature", + {".github/workflows/release-publish.yml": _wf(" - name: leak\n", _LEAK)}, "red"), + ("allowlist ⑥:ci.yml 里另一步(不叫 update-e2e)带该 feature", + {".github/workflows/ci.yml": _wf(*[ALLOWED_STEP_YAML, " - name: another\n", _LEAK])}, "red"), + ("P2-2 ①:`#` 在引号内、真命令在后面", + {".github/workflows/release-build.yml": _wf(f' - run: echo "#"; cargo build --features {FEATURE}\n')}, + "red"), + ("P2-2 ②:cargo hack --feature-powerset", + {".github/workflows/release-build.yml": _wf(_HACK.format(mode="--feature-powerset"))}, "red"), + ("P2-2 ③:cargo hack --each-feature", + {".github/workflows/release-build.yml": _wf(_HACK.format(mode="--each-feature"))}, "red"), + ("P2-2 ④:packaging 子目录脚本", + {"packaging/tests/build.sh": f"#!/bin/sh\ncargo build --features {FEATURE}\n"}, "red"), + ("P2-2 ⑤:.github/actions/** 复合 action", + {".github/actions/rebuild/action.yml": + "runs:\n using: composite\n steps:\n" + f" - run: cargo build --features {FEATURE}\n shell: bash\n"}, "red"), + ("P2-2 ⑥:Makefile 里的第二构建入口", + {"Makefile": f"build:\n\tcargo build --release --features {FEATURE}\n"}, "red"), + ("P2-2 ⑦:workflow 子目录", + {".github/workflows/nested/extra.yml": _wf(_LEAK)}, "red"), + ("P2-2 ⑧(防误杀):注释里提到该 feature", + {".github/workflows/release-build.yml": + _wf(f" # 这里不许加 {FEATURE}\n", " - run: cargo build --release\n")}, "green"), + ("Cargo ①:default 把 feature 带上", + {"crates/core/Cargo.toml": BASELINE["crates/core/Cargo.toml"] + 'default = ["update-test-source"]\n'}, + "red"), + ("Cargo ②:别的 feature 依赖它", + {"crates/core/Cargo.toml": BASELINE["crates/core/Cargo.toml"] + 'release = ["update-test-source"]\n'}, + "red"), ] +def _scan_case(files: dict[str, str]) -> list[str]: + with tempfile.TemporaryDirectory(prefix="ull-test-source-guard-") as temp: + root = Path(temp) + for relative, text in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return scan(root) + + def self_test() -> int: failures: list[str] = [] - with tempfile.TemporaryDirectory(prefix="ull-test-source-guard-") as temp: - base = Path(temp) - for relative, text in BASELINE.items(): - path = base / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - baseline = scan(base) - if baseline: - failures.append(f"基线应当在守门里是绿的,实际红了:{baseline}") - - for label, relative, injected in MUTATIONS: - target = base / relative - original = target.read_text(encoding="utf-8") - target.write_text(original + injected, encoding="utf-8") - hits = scan(base) - if not hits: - failures.append(f"{label}:注入了真实回归,守门却没红(抓不住)") - target.write_text(original, encoding="utf-8") - if scan(base): - failures.append(f"{label}:撤回注入后没有回到绿") - + baseline = _scan_case(BASELINE) + if baseline: + failures.append(f"基线应当绿,实际红了:{baseline}") + for label, changes, expect in VECTORS: + files = dict(BASELINE) + files.update(changes) + hits = _scan_case(files) + if expect == "red" and not hits: + failures.append(f"{label}:注入了真实回归,守门却没红(抓不住)") + if expect == "green" and hits: + failures.append(f"{label}:应当放行,实际红了(误杀):{hits}") if failures: for failure in failures: print(f"::error::{failure}", file=sys.stderr) return 1 - print(f"OK: 守门自测通过({len(MUTATIONS)} 条注入全部变红,基线绿)") + reds = sum(1 for _label, _changes, expect in VECTORS if expect == "red") + print(f"OK: 守门自测通过({reds} 条注入全红,{len(VECTORS) - reds + 1} 条基线/放行保持绿)") return 0 def main() -> int: parser = argparse.ArgumentParser(description=f"守门:官方发布产物里不许有 {FEATURE}") - parser.add_argument("--self-test", action="store_true", help="注入变异自测(每条都必须红)") + parser.add_argument("--self-test", action="store_true", help="注入变异自测(该红的红、该绿的绿)") parser.add_argument("--root", type=Path, default=ROOT, help="仓根(自测用)") args = parser.parse_args()