From d7adbd117275d7b9c2409a9e2e32eb9b91dc905b Mon Sep 17 00:00:00 2001 From: worker-updatee2e Date: Sun, 8 Nov 2026 11:27:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(m169):=20r3=20=E5=9B=9B=E6=9D=A1=E5=B0=B1?= =?UTF-8?q?=E5=9C=B0=E4=BF=AE=E5=AE=8C=20=E2=80=94=E2=80=94=20=E5=8F=AA?= =?UTF-8?q?=E8=AE=A4=E5=90=8C=E7=BA=A7=20name:=E3=80=81=E7=BA=B3=E5=85=A5?= =?UTF-8?q?=20.cargo/config.toml=E3=80=81=E7=99=BB=E8=AE=B0=20env=20?= =?UTF-8?q?=E4=BA=A4=E6=8E=A5=E8=BE=B9=E7=95=8C=E3=80=81=E6=A0=A1=E5=87=86?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E6=95=B0=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并门禁只认 clean,r3 的 p2-4items 就地修掉(无行为改动,判据更严,不放松任何一条)。 P2-1(一行 + 三向量)`_name_of` 现在只认**同级** `name:`(`item.end() == key_indent`):更深缩进的 `- name:`(`env:`/`with:` 子列表、`run: |` 块标量里的行)不再被当成项名;同一项里出现两个**不同** 的 `name:` 返回 `None`(无法判定 ⇒ 默认拒绝,不去猜 YAML 重复键语义)。新向量:name 藏在 env 子项 / 藏在 `run: |` 里 / 两个不同 name ⇒ 必须红。 P2-2 `.cargo/config*.toml` 进扫描面(`CONFIG_GLOBS`:根与 `crates/*` 下的 `config.toml`/`config`), 新增 `scan_cargo_config`:feature 字面量(覆盖 `--cfg feature="…"`、`[env] RUSTFLAGS`)走原有判据, `rustc-wrapper` / `rustc-workspace-wrapper` **一律判红**(wrapper 注入什么由程序决定,文本扫描看不见; 合法用途如 sccache 需要 review 明确同意,不能顺手加)。新向量:config.toml 用 `--cfg` 开该 feature ⇒ 红;设 rustc-wrapper ⇒ 红;普通 config.toml ⇒ 绿(防误杀)。 P2-3 运行期 env 交接写进守门自己的「抓不住」表(脚本头 + README §14):第一步把 feature 写进 `$GITHUB_ENV`、第二步只用 `--features "$FEAT"`——第二步没有字面量,**任何静态扫描都看不见**,这是本 守门的固有边界(引 YAML 解析器也修不了,因为这是数据流不是结构),不是漏判。 P2-4 两处过期数字:`release-guard.yml` 的「注入 14 条」→ 26 条注入 + 6 条防误杀/放行(另加基线, 条数以 `--self-test` 输出为准);README 的「26 条:21 红 + 6 绿」→「32 条向量:26 红 + 6 绿 + 基线」。 守门脚本 399 行(≤400,仍零第三方依赖);自测数据 32 条向量在 `packaging/tests/guard_vectors.json`。 验证(本机,未跑全量 cargo —— tower 已在合并态跑过 workspace 全量 EXIT=0/2648 passed): - `--self-test`:26 条注入全红 + 7 条基线/防误杀绿,exit 0。 - **真仓注入 8 组**(改真 ci.yml / 新建真 .cargo/config.toml,改完即还原):env 子项里的 name ⇒ 红; `run: |` 里的 name ⇒ 红;两个不同 name ⇒ 红;`--cfg feature=` ⇒ 红;`rustc-wrapper` ⇒ 红; 普通 config.toml ⇒ 绿;r2 的无名步骤 / flow 步骤回归 ⇒ 仍红;还原后基线绿。 - 正式扫描(真仓)exit 0;全仓 400 行扫描无超限;改动文件全 LF。 --- .github/workflows/release-guard.yml | 9 ++-- packaging/README.md | 12 +++-- packaging/check_test_source_off.py | 84 +++++++++++++++++++---------- packaging/tests/guard_vectors.json | 42 +++++++++++++++ 4 files changed, 110 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml index 9b2660d..c87a6cc 100644 --- a/.github/workflows/release-guard.yml +++ b/.github/workflows/release-guard.yml @@ -27,10 +27,11 @@ name: Release guard # app-iced 各有一条 `update-test-source` feature,打开后 `core::update::job::default_source` 可以被 # 两个环境变量指向任意本地目录——而更新链路的终点是**替换用户盘上的 exe**。它默认关,官方发布产物 # 里不许有它:守门扫 `.github/workflows/**`(含复合 action)、`packaging/**` 递归脚本、 -# `Makefile`/`justfile`/`Taskfile` 与各 `Cargo.toml`(`default` 里带上它也红,那种开法扫 workflow -# 是看不见的)。**唯一允许**是 `ci.yml` 里名为 `update-e2e` 的那一步(且必须带 `--test -# update_e2e_test`,文件名/步骤名/参数任一不符即红)。`--self-test` 注入 14 条真实回归(allowlist -# 六向量 + 七条绕过 + Cargo 两条),该红的红、该绿的绿。 +# `Makefile`/`justfile`/`Taskfile`、各 `Cargo.toml`(`default` 里带上它也红)与 `.cargo/config*.toml` +# (`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`)。**唯一允许**是 `ci.yml` 里名为 +# `update-e2e` 的那一步(且必须带 `--test update_e2e_test`,文件名/步骤名/参数任一不符即红)。 +# `--self-test` 注入 26 条真实回归 + 6 条防误杀/放行用例(另加基线;条数以 `--self-test` 输出为准), +# 该红的红、该绿的绿。 on: push: diff --git a/packaging/README.md b/packaging/README.md index 7080bda..3c3959e 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -66,7 +66,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | | `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤400 | | `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 | -| `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 26 条:21 红 / 6 绿),数据与代码分开 | ≤400 | +| `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | | `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 | | `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 | @@ -129,9 +129,13 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) (文件名 / 步骤名 / 参数任一不符即红,见脚本的 `ALLOWED_STEP`)。 归属按 **`jobs.*.steps[]` 的列表项**判(不是「最近一行 `- name:`」):落不到具名步骤上的行——`jobs:` 之前的孤儿行、项外键、没有 `name:` 的步骤、flow 风格步骤——**一律判红**(默认拒绝)。自测向量在 - `packaging/tests/guard_vectors.json`(26 条:21 条注入必须红 + 6 条基线/合法写法必须绿)。 - 抓不住(如实记):变量间接拼接(`FEAT="update-""test-source"`)、feature 改名后接回发布、`build.rs` - 里 spawn cargo、以及「把别的步骤**改名**成 `update-e2e` 并带 `--test update_e2e_test` 却干别的事」。 + `packaging/tests/guard_vectors.json`(32 条向量:26 条注入必须红 + 6 条合法写法必须绿,另加基线必须绿; + 条数以 `--self-test` 输出为准)。`.cargo/config*.toml` 也在扫描面里:`--cfg feature="…"` 与 + `rustc-wrapper` 都能绕过 `--features`(后者一律判红,合法用途要 review 明确同意)。 + 抓不住(如实记):变量间接拼接(`FEAT="update-""test-source"`)、**两步 `$GITHUB_ENV` 交接** + (第一步把 feature 写进 env、第二步只用 `--features "$FEAT"`——第二步没有字面量,任何静态扫描都 + 看不见,这是本守门的固有边界,不是漏判)、feature 改名后接回发布、`build.rs` 里 spawn cargo、以及 + 「把别的步骤**改名**成 `update-e2e` 并带 `--test update_e2e_test` 却干别的事」。 - 验收本体:`cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test --test update_e2e_tamper_test`(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。CI 里同一个命令挂在 `ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` + diff --git a/packaging/check_test_source_off.py b/packaging/check_test_source_off.py index 7282650..d62bb17 100644 --- a/packaging/check_test_source_off.py +++ b/packaging/check_test_source_off.py @@ -15,16 +15,17 @@ 2. **不许「打开所有 feature」**:`--all-features`、`cargo hack --feature-powerset|--each-feature|--all`。 3. **不许设成默认**:`Cargo.toml` 里它只许是**自己的声明**(含跨行数组);进 `default` 或别的 feature 的依赖数组即红。 -4. **扫描面逐项列举**(`WORKFLOW_GLOBS` / `SCRIPT_GLOBS` / `BUILD_ENTRY_GLOBS`):含 - `.github/actions/**`、`packaging/**` 递归子目录、`Makefile`/`justfile`/`Taskfile`。 +4. **扫描面逐项列举**(`WORKFLOW_GLOBS` / `SCRIPT_GLOBS` / `BUILD_ENTRY_GLOBS` / `CONFIG_GLOBS`): + 含 `.github/actions/**`、`packaging/**` 递归子目录、`Makefile`/`justfile`/`Taskfile`、以及 + `.cargo/config*.toml`(`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`,见 + `scan_cargo_config`)。 -注释剥离是**引号感知**的(`echo "#"; cargo build --features …` 里那个 `#` 不是注释)。 -抓得住/抓不住 的完整清单见 `packaging/README.md` §14。要点:变量间接拼接(`FEAT="update-""test-source"`)、 -feature 改名后接回发布、`build.rs` 里 spawn cargo、以及「有人把别的步骤**改名**成 `update-e2e` 并带 -`--test update_e2e_test` 却干别的事」都抓不住(扫描只看字符串;merge review 会看到 ci.yml 那一行)。 +注释剥离是**引号感知**的(`echo "#"; cargo build --features …` 里那个 `#` 不是注释)。抓不住的都是 +**故意手打**或**运行期数据流**(完整清单见 `packaging/README.md` §14):变量拼接、两步 `$GITHUB_ENV` +交接(第二步没有字面量——静态扫描的固有边界)、feature 改名后接回、`build.rs` 里 spawn cargo、改名复用。 -`--self-test` 把每条断言的**真实回归**注入临时副本,断言该红的红、该绿的绿(含 r2 的三条绕过与四条 -误杀回归)。守门自己抓不住回归时它会先红。 +`--self-test` 把每条断言的**真实回归**注入临时副本(数据在 `packaging/tests/guard_vectors.json`), +断言该红的红、该绿的绿。守门自己抓不住回归时它会先红。 """ from __future__ import annotations @@ -59,6 +60,15 @@ BUILD_ENTRY_GLOBS = ( "packaging/Makefile", ) MANIFEST_GLOBS = ("Cargo.toml", "crates/*/Cargo.toml") +#: cargo 配置:`--cfg feature="…"` 与 `rustc-wrapper` 都能在没有 `--features` 时打开这条通道(P2-2)。 +CONFIG_GLOBS = ( + ".cargo/config.toml", + ".cargo/config", + "crates/*/.cargo/config.toml", + "crates/*/.cargo/config", +) +#: 任意 wrapper 都能注入 `--cfg`,文本扫描看不见 ⇒ 一律判红。 +WRAPPER_KEY = re.compile(r"^\s*(rustc|rustc-workspace)-wrapper\s*=") #: 唯一允许出现该 feature 的地方:**文件 + 列表项名 + 必须同在的字符串**,三者不符即红。 ALLOWED_STEP = { "workflow": ".github/workflows/ci.yml", @@ -112,10 +122,12 @@ def _lines(path: Path) -> list[tuple[int, str]]: def _name_of(key_indent: int, body: list[tuple[int, str]]) -> str | None: """列表项的名字:项内**同级**的 `name:` 键(键序无关;块标量内容不当键)。 - 找不到 → `None` ⇒ 这一项落不到具名步骤 ⇒ 带 feature 就红(默认拒绝)。只认键位置上的 - `name:`:`run: echo "name: x"` 这种不算,`run: |` 块里的 `name: dummy` 也不算。 + 「同级」= 项首行 `- ` 之后那一列(`item.end() == key_indent`,r3 复审 P2-1):更深缩进的 + `- name:`(`with:` / `env:` 子列表、`run: |` 内容)**不算**项名。找不到、或出现两个**不同**的 + `name:` 都返回 `None` ⇒ 落不到具名步骤 ⇒ 带 feature 就红(默认拒绝)。 """ in_block = False + found: str | None = None for _number, line in body: text = line.strip() if not text or text.startswith("#"): @@ -125,19 +137,23 @@ def _name_of(key_indent: int, body: list[tuple[int, str]]) -> str | None: if in_block and indent > key_indent: continue in_block = False - if item: + if item and item.end() == key_indent: code = strip_comment(line[item.end() :]) # 项首行:` - name: x` → `name: x` - elif indent == key_indent: + elif not item and indent == key_indent: code = strip_comment(text) else: continue for pattern in (NAME_BLOCK, NAME_FLOW): match = pattern.match(code.strip()) if match: - return match.group(1).strip().rstrip("}").strip("'\"") + value = match.group(1).strip().rstrip("}").strip("'\"") + if found is not None and found != value: + return None + found = value + break if code.rstrip().endswith(BLOCK_SCALAR): in_block = True - return None + return found def split_items( @@ -145,8 +161,8 @@ def split_items( ) -> tuple[list[tuple[str | None, list[tuple[int, str]]]], list[tuple[int, str]]]: """按 YAML **列表项**切块,返回((项名, 该项的行), 孤儿行)。 - 每个 `- ` 起一项;比当前项更深的行归当前项,**同级或更浅的非列表行结束当前项**(那是 job 键 / - `steps:` / 顶层键)。落不到列表项上的行是孤儿——不属于任何步骤,带 feature 就红。 + 每个 `- ` 起一项;更深的行归当前项,**同级或更浅的非列表行结束当前项**(job 键 / `steps:` / + 顶层键)。落不到列表项上的行是孤儿——不属于任何步骤,带 feature 就红。 """ items: list[tuple[str | None, list[tuple[int, str]]]] = [] orphans: list[tuple[int, str]] = [] @@ -179,20 +195,11 @@ def split_items( def _reason(where: str, name: str | None, text: str) -> str | None: """`None` = 这一项被放行;否则返回「为什么红」的一句话(按最具体的那条给)。""" if where != ALLOWED_STEP["workflow"]: - return ( - f"`{FEATURE}` 只许出现在 `{ALLOWED_STEP['workflow']}` 的 " - f"`{ALLOWED_STEP['name']}` 步骤里(这里是 `{where}`)" - ) + return f"`{FEATURE}` 只许出现在 `{ALLOWED_STEP['workflow']}` 的 `{ALLOWED_STEP['name']}` 步骤里(这里是 `{where}`)" if name is None: - return ( - f"这一项没有 `name:`,落不到具名步骤(默认拒绝);`{where}` 里只有名为 " - f"`{ALLOWED_STEP['name']}` 的步骤可以用它" - ) + return f"这一项没有同级 `name:`,落不到具名步骤(默认拒绝);`{where}` 里只有 `{ALLOWED_STEP['name']}` 可以用它" if name != ALLOWED_STEP["name"]: - return ( - f"`{where}` 里只有名为 `{ALLOWED_STEP['name']}` 的步骤可以用 `{FEATURE}`" - f"(这一项叫 `{name}`)" - ) + return f"`{where}` 里只有名为 `{ALLOWED_STEP['name']}` 的步骤可以用 `{FEATURE}`(这一项叫 `{name}`)" if ALLOWED_STEP["must_contain"] not in text: return f"`{where}` 的 `{name}` 步骤少了 `{ALLOWED_STEP['must_contain']}`" return None @@ -238,7 +245,7 @@ def scan_workflow(path: Path, root: Path) -> list[str]: def scan_code_file(path: Path, root: Path) -> list[str]: - """扫脚本 / 第二构建入口:没有 allowlist,字面量或开关出现即红。""" + """扫脚本 / 构建入口 / cargo config:没有 allowlist,字面量或开关出现即红。""" where = path.relative_to(root).as_posix() problems: list[str] = [] for number, line in _lines(path): @@ -280,6 +287,24 @@ def scan_manifest(path: Path, root: Path) -> list[str]: return problems +def scan_cargo_config(path: Path, root: Path) -> list[str]: + """扫 `.cargo/config*.toml`:`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`。 + + r3 复审 P2-2 实测:`RUSTFLAGS='--cfg=feature="update-test-source"'` 能让 `#[cfg(feature = …)]` + 段在没有 `--features` 时编进来。`[build] rustflags` / `[env]` 靠 feature 字面量抓(走 + `scan_code_file`);`rustc-wrapper` 抓不住(注入什么由程序决定)⇒ 一律判红,合法用途要 review 同意。 + """ + problems = scan_code_file(path, root) + where = path.relative_to(root).as_posix() + for number, line in _lines(path): + if WRAPPER_KEY.match(strip_comment(line)): + problems.append( + f"::error file={where}:{number}::{where} 设了 rustc wrapper:wrapper 能注入任意 " + f"`--cfg`,静态扫描看不见它是否打开了 `{FEATURE}`(默认拒绝)" + ) + return problems + + def scan(root: Path) -> list[str]: """扫整个仓库,返回所有问题(空 = 绿)。""" problems: list[str] = [] @@ -287,6 +312,7 @@ def scan(root: Path) -> list[str]: (WORKFLOW_GLOBS, scan_workflow), (SCRIPT_GLOBS, scan_code_file), (BUILD_ENTRY_GLOBS, scan_code_file), + (CONFIG_GLOBS, scan_cargo_config), (MANIFEST_GLOBS, scan_manifest), ): for path in sorted({p for pattern in patterns for p in root.glob(pattern)}): diff --git a/packaging/tests/guard_vectors.json b/packaging/tests/guard_vectors.json index e2934c6..4233773 100644 --- a/packaging/tests/guard_vectors.json +++ b/packaging/tests/guard_vectors.json @@ -189,6 +189,48 @@ "files": { "Makefile": "build:\n\tcargo build --release --features update-test-source\n" } + }, + { + "label": "P2-1 ⑥:`name:` 写在更深缩进的 env 子项里", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: cargo build --release -p unlucky-cli --features update-test-source\n env:\n T: --test update_e2e_test\n HINT: |\n - name: update-e2e\n" + } + }, + { + "label": "P2-1 ⑦:`name:` 写在 `run: |` 块标量里", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: |\n name: update-e2e\n cargo build --release --features update-test-source --test update_e2e_test\n" + } + }, + { + "label": "P2-1 ⑧:同一项里两个不同的 `name:`", + "expect": "red", + "files": { + ".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n run: cargo build --release --features update-test-source --test update_e2e_test\n name: release-build\n" + } + }, + { + "label": "P2-2 ①:.cargo/config.toml 用 `--cfg feature=` 开该 feature", + "expect": "red", + "files": { + ".cargo/config.toml": "[build]\nrustflags = [\"--cfg\", 'feature=\"update-test-source\"']\n" + } + }, + { + "label": "P2-2 ②:.cargo/config.toml 设 rustc-wrapper", + "expect": "red", + "files": { + ".cargo/config.toml": "[build]\nrustc-wrapper = \"./tools/wrapper\"\n" + } + }, + { + "label": "误杀 ⑤:普通 .cargo/config.toml(无 feature / 无 wrapper)", + "expect": "green", + "files": { + ".cargo/config.toml": "[build]\njobs = 4\n" + } } ] }