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。
This commit is contained in:
worker-updatee2e authored and AskaEth committed 2026-11-08 11:12:11 +08:00
1 parent 24826c1a7c
commit 28bf104041
9 files changed
+357 -122

No files matched your search

+27
View File
@@ -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
+5 -2
View File
@@ -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:
+3 -1
View File
@@ -92,7 +92,9 @@ default = ["gpu"]
gpu = ["iced/wgpu"]
# M169:把「本地更新源」这条测试通道编进 GUI(默认关)。人工验收更新链路时的构建方式:
# ULL_UPDATE_DIR=<root> 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"]
+34
View File
@@ -6,9 +6,23 @@
//! 整个夹具只在 `update-test-source` feature 下真的被用到:那两个测试目标自己 `#![cfg]` 掉,
//! 这里只是跟着编译(不跑)。判据(feature + 两档环境变量)在 `core::update::job`。
//!
//! # 为什么这套「真形状」只在 Windows 上成立(r1 复审 P2-4)
//!
//! 产物名与形态按 **Windows** 的真实发布走:`unlucky-launcher_<ver>_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();
@@ -4,6 +4,10 @@
//! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到
//! 「修好清单也装不上」。
//!
//! ⚠ 这三例**抓不住回滚/备份**:摘要与长度失败都发生在 `install_staged` 之前,那一步走不到
//! (r1 复审 ⑤ 的变异实测:去掉 dest 备份后这三例仍全绿)。回滚由 `update_e2e_test.rs` 的备份
//! 断言与 `core::update::tests::install_cases` 守。
//!
//! 一键跑(必须带 feature;tower 定案:验收落在 release profile 上):
//!
//! ```text
+1
View File
@@ -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 备份后失败路径三例仍全绿,只有正路/幂等变红) |
//!
//! # 不验的部分(如实划界)
//!
+3 -2
View File
@@ -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"]
+9 -4
View File
@@ -121,8 +121,13 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
- **验收构建**:`cargo build --release --features update-test-source`,再配
`ULL_UPDATE_DIR=<root>` + `ULL_UPDATE_TEST_SOURCE=1`(两档缺一不可)。`ull update check --json` 会
如实写 `data.source: "file:<root>"`、`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 点击与重启替换(需人工)。
+271 -113
View File
@@ -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: <x>` 开始。
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: <step>` 把 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)
baseline = _scan_case(BASELINE)
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"基线应当绿,实际红了:{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}:注入了真实回归,守门却没红(抓不住)")
target.write_text(original, encoding="utf-8")
if scan(base):
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()