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
+359 -124

No files matched your search

+273 -115
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)
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()