From bff48e27fa47d0c2a2b145e36468596b1f9ff567 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 20:05:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(packaging):=20=E7=AD=BE=E5=90=8D=E5=B0=B1?= =?UTF-8?q?=E7=BB=AA=E5=BA=A6=E6=92=AD=E6=8A=A5=20+=20=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=89=8B=E5=86=8C=EF=BC=88M255=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M254 查出 `trust.rs` 的 `PRODUCTION` 表在 PINNED-KEYS 标记之间是空的 ⇒ 任何正式二进制都验不过 任何清单(fail-closed)。工具链早就齐了,缺的是操作步骤与可见性;本条补的就是这两样,不生成钥、 不改 Rust、不碰 release-publish 的判据。 - `packaging/check_signing_ready.py`(只读、零第三方依赖):三行读数 ① 信任表空不空 ② 环境有没有钥 (顺手对账「这把钥在不在表里」,种子一个字节都不打印)③ 最近一次发布的 .sig/清单痕迹(取不到写 「未知」)。退出码 0 就绪 / 1 未就绪(打印 fail-closed 后果 + 一行修复命令)/ 2 用法错。 - `packaging/tests/signing_ready_selftest.py`:判据自测,含**两个方向的变异重放**(退出码永远 0 ⇒ 判据①必须红;永远 1 ⇒ 判据②必须红)与接线判据(没人 uses: 它、没有 error 级注解、就绪度那步恒 exit 0)。 - `packaging/docs/signing-runbook.md`:人类照做的 5 步 + 每步判据 + 11 条已知坑 + 没验的部分(无任何密钥值)。 - `.github/workflows/signing-status.yml`:把三行搬进 job summary 的**播报** workflow。guards.yml 已 399/400,与 gpu-evidence.yml 同先例另开文件;刻意不声明 workflow_call:,未就绪用 ::warning::、 就绪度那步恒 exit 0,明确「不应被设为 required」。 - `packaging/README.md` / `packaging/docs/signing.md`:索引与指针。 --- .github/workflows/signing-status.yml | 105 +++++++ packaging/README.md | 9 + packaging/check_signing_ready.py | 200 +++++++++++++ packaging/docs/signing-runbook.md | 169 +++++++++++ packaging/docs/signing.md | 6 + packaging/tests/signing_ready_selftest.py | 339 ++++++++++++++++++++++ 6 files changed, 828 insertions(+) create mode 100644 .github/workflows/signing-status.yml create mode 100644 packaging/check_signing_ready.py create mode 100644 packaging/docs/signing-runbook.md create mode 100644 packaging/tests/signing_ready_selftest.py diff --git a/.github/workflows/signing-status.yml b/.github/workflows/signing-status.yml new file mode 100644 index 0000000..8dca2c4 --- /dev/null +++ b/.github/workflows/signing-status.yml @@ -0,0 +1,105 @@ +name: Signing status(播报,不是门禁) + +# 第一句就是口径:**这是播报,不是门禁。它红了不代表不能合,它绿了也不代表签名就绪。** +# 就绪度的结论只在 job summary 的三行字里(`packaging/check_signing_ready.py --line` 写的), +# 不在 job 的颜色里。**本 workflow 不应被设为 required check**(GitHub 侧分支保护里不要勾它)。 +# +# 为什么要有它(M255):M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` +# 表在 `PINNED-KEYS` 标记之间是空的,而空表一律 `KeyringEmpty` 拒装 ⇒ 今天任何正式二进制都验不过任何 +# 清单(fail-closed,用户端表现为「更新永远失败」)。工具链早就齐了,缺的是**可见性**:这三行读数让 +# 「今天差什么」在每次运行里都摆在台面上,而不是等发版时才发现。 +# +# 口径三条(由 `packaging/tests/signing_ready_selftest.py` 的接线判据机器校验,不是靠注释自律): +# ① 就绪度那一步抓 `rc=$?` 之后**恒 `exit 0`** —— 未就绪用 `::warning::` 播报,不用 error 级注解 +# (error 会让人以为构建挂了,与播报定位直接冲突)。真正的红留在 `release-publish.yml` 的签名步: +# 那里今天必然红是**对的**,因为签不出来的产物本来就不能发。 +# ② 文件名/名字都不带 guards、check 字样:它不是守卫,是一条状态播报。 +# ③ 本文件**刻意不声明 `workflow_call:`**:没有这个触发器,别的 workflow 就没法 `uses:` 它 —— +# 「不能被接进 dev 线变成必需检查」是一条机制,不是一句嘱咐。接线自检另外扫全部 workflow, +# 任何 `uses: …/signing-status.yml` 出现即红(防的是有人先加 workflow_call 再接进来)。 +# +# 判据本体与 0/1/2 的语义:`packaging/check_signing_ready.py` 的文件头; +# 人类要做的那几步:`packaging/docs/signing-runbook.md`。 + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + # 尺子自测:它红 = **判据脚本坏了**(不是「签名没就绪」)。两者措辞在 summary 里分开写。 + # 这一步照常会红,因为「尺子坏了」本来就该有人看见 —— 但它不阻断任何东西:本 workflow 独立、 + # 非必需,也不在 `dev-guards.yml` 的 `uses:` 里。 + selftest: + name: 判据自测(三行/退出码/变异重放/接线) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: 三行读数 + 0/1/2 语义 + 「退出码改绿必须被抓到」+ 接线不是门禁 + run: python3 packaging/tests/signing_ready_selftest.py + - name: 真跑三种情形(今天的状态 / 假 PinnedKey 副本 / 用法错) + run: | + set -uo pipefail + work="$RUNNER_TEMP/signing-ready" + mkdir -p "$work" + # 结论只打印,不判定:这里要的是「三种情形各自的退出码与三行读数长什么样」, + # 判据在上一跑的 selftest 里。所有 `|| true` 只为让这一步本身不变红。 + run_and_show() { # run_and_show <说明> <命令...> + note="$1"; shift + echo "── $note" + python3 packaging/check_signing_ready.py "$@" 2>&1 || true + echo + } + run_and_show "今天的状态(真实 trust.rs:表空 ⇒ 未就绪)" + # 副本夹一条假 PinnedKey(**不碰真实 trust.rs**):造出「就绪」的正路长什么样。 + python3 - "$work" <<'PY' + import sys + from pathlib import Path + + trust = Path("crates/core/src/update/trust.rs").read_text(encoding="utf-8") + begin = trust.index(">>> PINNED-KEYS >>>") + len(">>> PINNED-KEYS >>>") + end = trust.index("<<< PINNED-KEYS <<<") + fake = ' PinnedKey { key_id: "demo-not-a-real-key", public_key_hex: "' + "ab" * 32 + '" },\n' + Path(sys.argv[1], "trust-with-fake-key.rs").write_text( + trust[:begin] + "\n" + fake + trust[end:], encoding="utf-8" + ) + PY + run_and_show "副本夹一条假 PinnedKey(②仍缺钥 ⇒ 仍未就绪,但 ① 会变就绪)" \ + --trust "$work/trust-with-fake-key.rs" + run_and_show "用法错(不存在的 trust.rs ⇒ 2)" --trust "$work/nope/trust.rs" || true + + # 播报:只把三行搬进 job summary,不看颜色。 + status: + name: 签名就绪度(播报) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # ② 要读「环境里有没有钥」,所以把 Secret 传进来(与 release-publish.yml 的做法一致)。 + # 脚本只回显 key_id 与**公钥**,种子(seed_hex)一个字节都不打印 —— 用例 no_seed_leak 钉着这条。 + - name: 三行就绪度(脚本给 0/1/2;本步恒 exit 0) + env: + ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }} + run: | + set -uo pipefail + set +e + python3 packaging/check_signing_ready.py --line > "$RUNNER_TEMP/signing-ready.txt" 2>&1 + rc=$? + set -e + cat "$RUNNER_TEMP/signing-ready.txt" + case "$rc" in + 0) echo "::notice::签名就绪:信任表非空,且环境里的钥与它同源。这是一条播报,不是门禁。" ;; + 1) echo "::warning::签名未就绪:今天任何正式二进制都验不过任何清单(fail-closed)。该做什么见 packaging/docs/signing-runbook.md §2 的下单清单。" ;; + *) echo "::warning::就绪度脚本用不了(rc=$rc,用法错):这是尺子的问题,不是签名状态 —— 看上面的 stderr。" ;; + esac + { + echo + echo "- 本 workflow 是**播报**,不是门禁:**不应被设为 required**,也不阻断任何其它 job。" + echo "- 颜色 ≠ 结论:绿色只说明脚本跑完了;结论只看上面三行(尤其 ①「信任表」)。" + echo "- fork / Dependabot 的 PR 运行拿不到仓库 Secret ⇒ ② 必然是「未就绪」;以 main 上的运行为准。" + echo "- 真门禁在哪:\`release-publish.yml\` 的签名步(\`--check-pinned-keyring\` / \`--sign\` / \`--verify-pinned\`)。" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 diff --git a/packaging/README.md b/packaging/README.md index c1268ad..5e7cf53 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -81,6 +81,10 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 | | `packaging/check_test_key_off.py` | 测试公钥守门(M190 r3):把公开的测试公钥并进信任表的那条 feature 只许出现在 `crates/core/Cargo.toml` 自己的声明与 `crates/presenter/Cargo.toml` 的 `[dev-dependencies]` 段内,其余(workflow / 打包脚本 / 根 `Cargo.toml` / `.cargo/config*.toml` / presenter 的 `[dependencies]`)出现即红;`--self-test` 注入 7 条向量(5 红 / 2 绿)+ 基线;其中一条专治「core 的声明里偷偷带子 feature」(`update-test-key = ["update-test-source"]` ⇒ 红) | ≤400 | | `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 | +| `packaging/check_signing_ready.py` | 签名就绪度播报(M255):**只读**、零第三方依赖,三行读数 —— ① `PRODUCTION` 表空不空(解析 `PINNED-KEYS` 标记之间)② 环境里有没有钥(`ULL_MANIFEST_SIGNING_KEY` / `--key`,顺手对账「这把钥在不在表里」,**种子一个字节都不打印**)③ 最近一次发布的 `.sig`/清单痕迹(取不到写「未知」,不写「无」)。退出码 `0` 就绪 / `1` 未就绪(打印「这会让已安装用户永远验不过(fail-closed)」+ 一行修复命令)/ `2` 用法错(含标记丢了 —— 那时形不成判断) | ≤400 | +| `packaging/tests/signing_ready_selftest.py` | 上一条的判据自测(M255):表空必 `exit 1` 且三行都打印 / **副本**夹一条假 `PinnedKey` 变 `exit 0`(不碰真实 `trust.rs`)+ 不泄种子 / ② 对账 / ③ 有-无-未知 / 用法错 = 2 / `--line` 写 summary / **两个方向的变异重放**(「退出码永远 0」⇒ 判据①必须红;「永远 1」⇒ 判据②必须红)/ **接线判据**:没有别的 workflow `uses:` 它、文件里没有 error 级注解、就绪度那步 `rc=$?` 后 `exit 0`。不覆盖:整个 workflow 被删后没人跑它(与 `gpu_shot_ci_selftest.py` 同一条边界) | ≤400 | +| `.github/workflows/signing-status.yml` | 就绪度**播报**接线(M255):把三行搬进 job summary。**不是门禁**:未就绪用 `::warning::`、就绪度那步**恒 `exit 0`**、**不应被设为 required**、刻意不声明 `workflow_call:`(别人没法 `uses:` 它,接线自检另外扫全仓防「顺手接进 dev 线」)。同 job 另有一步跑上面的自测(那一步会红 = 尺子坏了,与「未就绪」分开写)。新开文件而不是并进 `guards.yml`:那份已 399/400 行 | ≤400 | +| `packaging/docs/signing-runbook.md` | **人类照做**的签名手册(M255):现状读数 → 5 步下单清单(生成钥 / Secret 名字逐字 / 贴 `PinnedKey` / 发带 `.sig` 的 Release / `--verify-pinned` 回验)→ 每步判据 → 11 条已知坑 → 发布后回验 → 播报≠门禁 → **没验的部分**(Authenticode、托管签名、真机更新端到端、本手册命令未真跑)。不含任何真实密钥,全是占位符 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | | `packaging/docs/linux-evidence.md` | Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤400 | | `packaging/docs/gpu-evidence.md` | GPU 截图证据账(M249 + §8 CI 接线 M253:无 GPU 显式跳过、跳过 ≠ 通过、21 条接线变异):判据逐条「抓得住什么/抓不住什么」、本机实跑退出码、**能证**(字形栅格与边缘 AA / 面板 α 与玻璃 / 两后端一致性)与**不能证**(首帧与帧率 / 整窗 α 与圆角观感,走 DWM 任何截图都拍不到)、历史 `--diff` 基线永久不可复核 | ≤400 | @@ -215,5 +219,10 @@ CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复 的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的 **编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装, **不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。 +- **签名就绪度与操作步骤(M255)**:今天 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的** ⇒ 任何正式 + 二进制都验不过任何清单(fail-closed)。读数一条命令:`python3 packaging/check_signing_ready.py` + (三行 + `0/1/2`,未就绪时打印一行修复命令);CI 里同一条读数在 `signing-status.yml` 的 job summary + 上,**那是播报不是门禁**(不应设为 required)。要动手做的那 5 步在 + [`docs/signing-runbook.md`](docs/signing-runbook.md)(不含任何真实密钥)。 - **不得混淆**:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,**不解决 SmartScreen** ——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。 diff --git a/packaging/check_signing_ready.py b/packaging/check_signing_ready.py new file mode 100644 index 0000000..d2cf3ad --- /dev/null +++ b/packaging/check_signing_ready.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""签名就绪度播报(M255):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。 + +为什么要它:M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 +`PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 于是**今天任何正式二进制都验不过 +任何清单**(fail-closed 是成立的,但在用户机器上表现为「更新永远失败」)。工具链早就齐了 +(`sign_manifest.py` 的 `--generate-key` / `--check-pinned-keyring` / `--sign` / `--verify-pinned`), +缺的是**操作步骤与可见性**。本脚本只负责「现在差什么」,它不生成钥、不写任何文件、不改 `trust.rs`。 + +三行就绪度(各一条判据,互不替代): + +| 行 | 判据 | 未就绪意味着 | +|---|---|---| +| ① | `PRODUCTION` 表非空:解析 `PINNED-KEYS` 标记之间的 `PinnedKey` 条目,0 条即未就绪 | 客户端一把公钥都没有,任何清单都验不过(今天就是这个状态) | +| ② | 环境里有签名钥:`ULL_MANIFEST_SIGNING_KEY`(或 `--key <文件>`)能解出 32 字节种子,**且**它的公钥在 ① 的表里 | 没钥签不了;钥与信任表不是同一把 ⇒ 发布时 `--check-pinned-keyring` 当场红 | +| ③ | 最近一次发布的 `manifest.json` / `.sig` 痕迹 | **不参与就绪判定**。取不到证据就写「未知」——「本地没留档」不等于「上次没签」 | + +②里的对账(`公钥 ∈ 表`)是刻意加的:只说「有钥」会给出一个**看着就绪、发布却红**的假象,与本仓 +「别再造假绿的尺子」的血泪账直接冲突。公钥是公开物,回显它是安全的;**种子(`seed_hex`)在本脚本里 +一次都不打印**。 + +退出码是对外接口(改动即行为变化,`packaging/tests/signing_ready_selftest.py` 钉着它): +`0` 就绪 / `1` 未就绪 / `2` 用法错(含 trust.rs 读不到、标记丢了 —— 那时脚本形不成判断,不是「未就绪」)。 + +**它不是门禁**:CI 里的 `.github/workflows/signing-ready.yml` 只把这三行搬进 job summary, +不因「未就绪」变红。理由见 `packaging/docs/signing-runbook.md` §6。 + + python3 packaging/check_signing_ready.py + python3 packaging/check_signing_ready.py --trust <副本 trust.rs> --key <钥文件> --dist dist +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SIGN = ROOT / "packaging/sign_manifest.py" +RUNBOOK = "packaging/docs/signing-runbook.md" +#: 未就绪时的固定措辞:判据与「人类一眼看懂后果」绑在一起(用例按字面断言这一句)。 +FAIL_CLOSED = "这会让已安装用户永远验不过(fail-closed)" + +sys.path.insert(0, str(ROOT / "packaging")) + +# 标记 / 环境变量名 / 信任表路径一律**从 sign_manifest.py 取**,不在这里写第二份字面量: +# 两份标记各自漂开时,`--check-pinned-keyring` 与就绪度会给出互相矛盾的读数。 +from sign_manifest import KEY_ENV, KID_RE, PIN_BEGIN, PIN_END, TRUST_RS, load_key # noqa: E402 +from ed25519_ref import public_key # noqa: E402 + +Line = tuple[bool, str] # (就绪?, 该行文本) + + +def table_state(path: Path) -> tuple[bool, str, list[tuple[str, str]]]: + """① 判据:`PINNED-KEYS` 标记之间有至少一条 `PinnedKey`。 + + 标记缺失 / 文件读不到 ⇒ `RuntimeError`(调用方翻成退出码 2:脚本无法形成判断)。 + """ + text = path.read_text(encoding="utf-8") + begin, end = text.find(PIN_BEGIN), text.find(PIN_END) + if begin < 0 or end < begin: + raise RuntimeError( + f"{path} 里找不到 {PIN_BEGIN} / {PIN_END} 标记(标记本身不许动 —— sign_manifest.py 也按它解析)" + ) + inside = text[begin:end] + keys = KID_RE.findall(inside) + line_no = text[:begin].count("\n") + 1 + if keys: + names = "、".join(f"{kid}({pk[:16]}…)" for kid, pk in keys) + return True, f"就绪 —— 标记之间 {len(keys)} 条:{names}({path}:{line_no} 起)", keys + # 空表就是今天的状态。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。 + # ⚠ 本仓另有一把 `TEST_ONLY` 测试钥**本来就在标记之外**(那是它的正确位置),所以这里只**点名**, + # 不替人下结论 —— 上一版写成「贴错位置,等于没钉」,在真实 trust.rs 上是误报(实测抓出来的)。 + strays = [kid for kid, _ in KID_RE.findall(text[:begin]) + KID_RE.findall(text[end:])] + hint = "" + if strays: + hint = ( + f";文件里另有 {len(strays)} 条在标记之外(key_id:{'、'.join(strays[:3])})" + "—— `TEST_ONLY` 那条本来就在外面,属正常;**生产钥**贴在外面等于没钉" + ) + return False, f"未就绪 —— 标记之间 0 条 PinnedKey{hint}({path}:{line_no} 起)", [] + + +def key_state(key_file: str | None, pinned: list[tuple[str, str]]) -> tuple[bool, str, str | None]: + """② 判据:环境里有钥、解析得出、且它的公钥在 ① 的表里。 + + 返回 `(就绪?, 该行文本, 可直接贴进 trust.rs 的那一行 PinnedKey)`。 + **任何分支都不打印种子**;回显的只有 `key_id` 与公钥十六进制。 + """ + raw = Path(key_file).read_text(encoding="utf-8") if key_file else os.environ.get(KEY_ENV, "") + source = f"--key {key_file}" if key_file else f"环境变量 {KEY_ENV}" + if not raw.strip(): + return False, f"未就绪 —— {source} 未设置/为空(没有私钥就签不出任何清单)", None + try: + key_id, seed = load_key(raw) + except SystemExit as error: # load_key 用 SystemExit 报错(它会 sys.exit);这里降级成一行读数 + return False, f"未就绪 —— {source} 读不出钥:{error}", None + pk_hex = public_key(seed).hex() + paste = f'PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},' + for pinned_id, pinned_hex in pinned: + if pinned_hex.lower() == pk_hex: + note = "" if pinned_id == key_id else f"(表里叫 {pinned_id},钥文件叫 {key_id};判据是公钥字节,能过)" + return True, f"就绪 —— {source} 里有钥 {key_id},公钥 {pk_hex} 已在信任表里{note}", paste + if pinned: + return False, f"未就绪 —— {source} 里有钥 {key_id}(公钥 {pk_hex}),但**不在**信任表里,发布时会被拦", paste + return False, f"未就绪 —— {source} 里有钥 {key_id}(公钥 {pk_hex}),但信任表是空的,没处钉", paste + + +def release_state(dist: Path | None, assets: Path | None) -> tuple[bool | None, str]: + """③ 判据:最近一次发布有没有 `manifest.json` / `.sig` 痕迹。**只取证,不参与就绪判定。** + + `None` = 未知(既没给 `--dist` 里的留档,也没给 `--release-assets`)。「本地没留档」不是 + 「上次没签」的证据,所以这一档只写「未知」,不写「无」。 + """ + if dist is not None and dist.is_dir(): + manifest = dist / "manifest.json" + signature = dist / f"{manifest.name}.sig" + if manifest.is_file() and signature.is_file(): + return True, f"有 —— 本地留档 {manifest} + {signature}" + if manifest.is_file(): + return False, f"无 —— 本地 {dist} 里有 manifest.json 但**没有** .sig(那份清单客户端会拒收)" + if assets is not None and assets.is_file(): + text = assets.read_text(encoding="utf-8", errors="replace") + has_manifest = "manifest.json" in text + has_sig = f"manifest.json.sig" in text + if has_manifest and has_sig: + return True, f"有 —— {assets} 的资产清单里同时有 manifest.json 与 manifest.json.sig" + if has_manifest: + return False, f"无 —— {assets} 的资产清单里有 manifest.json,但没有 manifest.json.sig(没签名)" + return False, f"无 —— {assets} 的资产清单里既没有 manifest.json 也没有 .sig(那次发布没走上签名这条链)" + looked = [str(p) for p in (dist, assets) if p is not None] + where = f"(看过:{'、'.join(looked)})" if looked else "(没给 --dist / --release-assets,也没本地 dist/ 留档)" + return None, f"未知 —— 取不到任何发布产物证据{where}" + + +def fix_hint(table_ok: bool, key_ok: bool, paste: str | None) -> str: + """未就绪时的**一行**修复命令(不带任何密钥值;钥的事只指向 Secret 名与手册)。""" + if not table_ok and paste: + return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §3)" + if not table_ok: + return ( + "修复:python3 packaging/sign_manifest.py --generate-key --key-id " + f"→ 私钥贴进 Secret {KEY_ENV},打印的 PinnedKey 那行贴进 trust.rs 标记之间(手册 {RUNBOOK} §1–§3)" + ) + if not key_ok and paste: + return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §3)" + return f"修复:把密钥贴进仓库 Secret {KEY_ENV}(内容 = --generate-key 打印的那行 JSON;手册 {RUNBOOK} §2)" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="签名就绪度播报:三行读数 + 0/1/2 退出码(M255)") + parser.add_argument("--trust", type=Path, default=TRUST_RS, help=f"trust.rs 路径(默认 {TRUST_RS})") + parser.add_argument("--key", metavar="FILE", help=f"钥文件(不给则读环境变量 {KEY_ENV})") + parser.add_argument("--dist", type=Path, default=Path("dist"), help="本地留档目录(默认 dist/;不存在则不看)") + parser.add_argument("--release-assets", type=Path, metavar="FILE", help="某次 Release 的资产清单文本(如 gh release view 的输出)") + parser.add_argument("--line", action="store_true", help="CI 用:额外把三行写进 $GITHUB_STEP_SUMMARY") + args = parser.parse_args(argv) + + try: + table_ok, table_line, pinned = table_state(args.trust) + except (OSError, UnicodeDecodeError, RuntimeError) as error: + print(f"error: 读不了信任表:{error}(脚本无法形成判断,按用法错退出)", file=sys.stderr) + return 2 + key_ok, key_line, paste = key_state(args.key, pinned) + release_ok, release_line = release_state(args.dist, args.release_assets) + + lines: list[Line] = [ + ("① 信任表 PRODUCTION", table_ok, table_line), + ("② 环境签名钥", key_ok, key_line), + ("③ 最近一次发布痕迹", release_ok is not False, release_line), + ] + for label, _ok, text in lines: + print(f"{label}:{text}") + + ready = table_ok and key_ok + if ready: + print(f"结论:就绪({args.trust} 里钉了钥,环境里的钥与它同源)—— 可以按 {RUNBOOK} §4 发一次带 .sig 的 Release 并用 --verify-pinned 回验") + else: + blockers = [label for label, ok, _ in lines[:2] if not ok] + print(f"结论:未就绪(差 {'、'.join(blockers)})—— {FAIL_CLOSED}") + print(fix_hint(table_ok, key_ok, paste)) + + if args.line: + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + body = ["## 签名就绪度(M255,播报不是门禁)", ""] + body += [f"- **{label}**:{text}" for label, _ok, text in lines] + body += [ + "", + f"- 结论:**{'就绪' if ready else '未就绪'}**", + f"- 本 job 的绿色**不代表签名就绪**:判据只看上面三行字,退出码恒为 0(见 `{RUNBOOK}` §6)。", + ] + Path(summary).write_text("\n".join(body) + "\n", encoding="utf-8") + + return 0 if ready else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/docs/signing-runbook.md b/packaging/docs/signing-runbook.md new file mode 100644 index 0000000..09b12be --- /dev/null +++ b/packaging/docs/signing-runbook.md @@ -0,0 +1,169 @@ +# 签名就绪度操作手册(M255) + +本文件是**人类照做**的手册:从「信任表是空的」走到「发出去的 Release 用客户端判据能验过」。 +背景/威胁模型/轮换纪律在 [`signing.md`](signing.md);本文件只讲**步骤、命令、坑**。 + +> ⚠ 本文件**不含**任何真实私钥、种子或公钥。所有示例都是 `<尖括号占位符>`。 +> `--generate-key` 的输出是**敏感物**:只在你要贴进 Secret 的那一次运行里存在,别存文件、别贴 issue。 + +## 0. 现状读一遍(2026-11 的 M254 审计结论) + +`crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的**。空表 = +`KeyringEmpty` 一律拒装 ⇒ **今天任何正式二进制都验不过任何清单**(fail-closed 成立,但用户端表现为 +「更新永远失败」)。已存在的部分(都能用): + +| 环节 | 在哪 | 今天的状态 | +|---|---|---| +| 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一行 | +| 钥 ↔ 信任表对账 | `sign_manifest.py --check-pinned-keyring` | 有;**今天必然红**(表空 ⇒ 「信任表是空的」) | +| 签名 | `sign_manifest.py --sign dist/manifest.json` → `.sig` | 有;**今天必然红**(`ULL_MANIFEST_SIGNING_KEY` 没有,前面先红) | +| 回验 | `sign_manifest.py --verify-pinned dist/manifest.json` | 有;**今天必然红**(表空 ⇒ 「无从回验」) | +| 发布接线 | `release-publish.yml` 的「签更新清单」步 | 有;上面四条任一非零 ⇒ 不出 Release(**这是对的**) | +| 资产上传 | `release-publish.yml` 的 `assets=(… manifest.json manifest.json.sig …)` | 有 | +| **操作步骤与可见性** | 本文件 + `packaging/check_signing_ready.py` + `signing-status.yml` | **本次补上的就是这两样** | + +一句话:**工具链不缺,缺的是有人把钥钉进表里。** 那件事只有人类能做(塔不会自己生成生产私钥)。 + +## 1. 先看读数(不用碰任何钥) + +```bash +python3 packaging/check_signing_ready.py # 三行读数 + 退出码 +python3 packaging/check_signing_ready.py --dist dist # 顺手看一眼本地留档 +``` + +三行分别是 ① `PRODUCTION` 表空不空 ② 环境里有没有签名钥(并顺手对账「这把钥在不在表里」) +③ 最近一次发布的 `.sig`/清单痕迹(取不到就写「未知」——本地没留档 ≠ 上次没签)。 + +退出码:`0` 就绪 / `1` 未就绪 / `2` 用法错(参数写错、`trust.rs` 读不到、标记丢了)。 +**未就绪时会打印一行修复命令**,直接照抄即可。CI 里同一条读数在 `Signing status(播报)` workflow 的 +job summary 里(见 §6 —— 它是播报,不是门禁)。 + +## 2. 人类要亲手做的 5 步(下单清单) + +下面每一行都只有命令与占位符,**没有任何密钥值**。做完一步就把该步的验证跑一遍。 + +```bash +# ① 生成钥(⚠ 输出是敏感物;不要跑在 CI 里,不要重定向到仓库里的文件) +python3 packaging/sign_manifest.py --generate-key --key-id + +# ② 把打印出来的那一行 JSON({"key_id": …, "seed_hex": …})贴进仓库 Secret: +# Settings → Secrets and variables → Actions → New repository secret +# 名字必须**逐字**是:ULL_MANIFEST_SIGNING_KEY +# (等价 CLI:gh secret set ULL_MANIFEST_SIGNING_KEY,然后按提示粘贴,别用 -b 把值留在 shell 历史里) + +# ③ 把打印出来的那一行 PinnedKey { … } 贴进 trust.rs 的 PINNED-KEYS 标记之间(标记本身别动) +grep -n 'PINNED-KEYS' crates/core/src/update/trust.rs +# 贴完提交这次改动 —— 公钥进 git 是对的:它是信任锚,本来就该公开可审计 + +# ④ 对账(需要一个有钥的环境:本机就先 export,别写进任何文件) +python3 packaging/sign_manifest.py --check-pinned-keyring # 期望:ok: 签名钥 已被钉住 + +# ⑤ 发一次带 .sig 的 Release,然后用**客户端的判据**回验(这一步不需要私钥) +python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # 期望:ok: 用钉住的公钥能验过 +``` + +`--verify-pinned` 才是证据:`--sign` 说「签好了」不算 —— 客户端拿的是 `trust.rs` 里那把公钥。 +发布后建议再用**下载下来的资产**验一次(§5),因为「CI 上能验过」不等于「上传的那份就是被签的那份」。 + +### 每一步的判据(怎么知道这步真的成了) + +| 步 | 成功长什么样 | 失败长什么样 | +|---|---|---| +| ① | 打印 ① 私钥 JSON、② 一行 `PinnedKey`、③ 公钥十六进制;脚本自带「自己签自己验」自检 | `error: 自己签自己验不过` ⇒ 不要用这把钥,环境有问题 | +| ② | `gh secret list` 里能看到 `ULL_MANIFEST_SIGNING_KEY` | 列表里没有 / 名字拼错(大小写、下划线) | +| ③ | `check_signing_ready.py` 的 ① 从「未就绪」翻成「就绪」 | ① 仍说 0 条 ⇒ 贴到了标记**外面**(读数里会点名「另有 N 条在标记之外(key_id:…)」) | +| ④ | `ok: 签名钥 … 已被钉住` | `签名钥(…)不在信任表里` ⇒ ②③ 不是同一把钥 | +| ⑤ | `ok: manifest.json.sig 用钉住的公钥 … 能验过(客户端会接受)` | `拿 trust.rs 里钉住的公钥回验不过 —— 这份 Release 的用户会全部拒装。别发版` | + +## 3. 一次发布里签名是怎么被执行的(不用额外操作) + +`release-publish.yml` 的「签更新清单」步按顺序跑四条,任一非零 ⇒ 本步红 ⇒ 不出 Release: + +```bash +sign_manifest.py --self-test # RFC 8032 向量:脚本自己没坏 +sign_manifest.py --check-pinned-keyring # Secret 里的钥必须已在 trust.rs 钉住 +sign_manifest.py --sign dist/manifest.json # 产出 dist/manifest.json.sig +sign_manifest.py --verify-pinned dist/manifest.json # 用钉住的公钥回验产出 +``` + +这一步已经把「未配置 Secret」「钥没钉」「钥不匹配」都变成**发版前的红**,所以「发了但没签名」这条 +不会发生 —— 它只会在发布 job 上红,不会静默发出去。 + +## 4. 已知坑(按踩到的概率排序) + +1. **Secret 名字拼错**(`ULL_MANIFEST_...` vs `ULL_MANIFEST_KEY`):可复用工作流只拿得到自己**声明过**的 + secret,名字不对就是空串 ⇒ 签名步红,而错误文案会反过来指控「Secret 没配」。`release-publish.yml` + 已经声明了这一个(`required: false`,缺省走 fail-closed),照抄名字即可。 +2. **只配 Secret 没钉表**:`--check-pinned-keyring` 报「信任表是空的(fail-closed)」,并**直接打印** + 该贴的那一行 `PinnedKey`(只含公钥,安全)。照贴即可。 +3. **只钉表没配 Secret**:`--check-pinned-keyring` 报「没有签名密钥」。这不影响已有用户(表非空 ⇒ + 他们能验签),但发不了新版。 +4. **Secret 与 trust.rs 是两把不同的钥**:最常见于「换过钥但只更新了一边」。判据是**公钥字节**, + `key_id` 只是标签;`--check-pinned-keyring` 会把两边的公钥都打出来对账。 +5. **把条目贴到标记外面**(`];` 之后、或贴着别的 `const`):等于没钉。`check_signing_ready.py` 会**点名** + 标记之外的 `key_id`;注意本仓 `TEST_ONLY` 那条**本来就在标记之外**(那是它的正确位置),所以读数是 + 「另有 N 条在标记之外(key_id:…)」而不是「贴错了」—— 对着 `key_id` 自己认。 +6. **`set -x` / `echo $ULL_MANIFEST_SIGNING_KEY`**:一次就够把私钥泄进日志。工作流里那一步显式 `set +x` + 且只回显**公钥**;本机调试同理,别开 trace。 +7. **`--generate-key` 的输出落进文件/issue/聊天**:当成泄露处理,走 `signing.md` §5 轮换(旧钥签新钥)。 +8. **fork / Dependabot 的 PR 拿不到仓库 Secret**:那种运行里 `check_signing_ready.py` 的 ② 必然是 + 「未就绪」,这是**环境限制**,不是仓库状态。以 `main` 上的读数为准。 +9. **`.sig` 没上传到 Release**(手改过 `assets=(…)` 那一行):客户端取 `<产物根>/manifest.json.sig` + 会 404 ⇒ 所有人验签失败。上传后请在资产列表里**亲眼看到** `manifest.json` 与 `manifest.json.sig` + 两个名字(§5 的 `gh release view` 那条命令就是干这个的)。 +10. **删掉旧钥条目太早**:用户机器上装着旧二进制,它只认旧钥;删条目的那个版本必须**只**由新钥签, + 并且至少一个发布周期的双签名之后(`signing.md` §5)。 +11. **「昨天验过」不等于「今天验得过」**:`PRODUCTION` 是**编译期**常量,改 `trust.rs` 只影响**下一次构建**。 + 已在用户盘上的旧二进制永远用旧表 —— 这是设计,不是 bug。 + +## 5. 发布后回验(发布侧,不需要私钥) + +```bash +# 资产里必须两个名字都在(漏一个等于全员验签失败) +gh release view --json assets --jq '.assets[].name' | grep -E 'manifest\.json(\.sig)?$' + +# 拿下载回来的真实资产复现客户端判据:manifest.json 与 .sig 必须成对、且是同一份字节 +mkdir -p /tmp/verify && gh release download --pattern 'manifest.json*' --dir /tmp/verify +python3 packaging/sign_manifest.py --verify-pinned /tmp/verify/manifest.json +``` + +退出码 0 = 「客户端会接受这份清单」。非零时**不要**手动 `edit` Release 绕过 —— 先看脚本 stderr +(它会说清楚是表空、无 `.sig`、还是签名对不上)。 + +## 6. CI 里那条读数:`Signing status(播报)` + +`.github/workflows/signing-status.yml` 每天在 `main` / PR / 手动 dispatch 上跑一次,把 §1 的三行搬进 +job summary。**它不是门禁**: + +* 它**不应被设为 required check**(分支保护里不要勾它),也不阻断任何其它 job; +* 它就绪度那一步**恒 `exit 0`**:未就绪用 `::warning::` 播报,不用 error 级注解(那会让人以为构建挂了); +* 它**刻意不声明 `workflow_call:`**,所以别的 workflow 没法 `uses:` 它;接线自检还会扫全部 workflow, + 任何 `uses: …/signing-status.yml` 出现即红(防的是「顺手接进 dev 线把播报变成门禁」); +* 它**绿**只说明脚本跑完了;结论只看 summary 里的三行字。 +* 同一 workflow 里另有一步跑 `packaging/tests/signing_ready_selftest.py`:那一步**会红**,红 = **尺子坏了** + (三行读数/退出码/变异重放/接线判据之一没过),与「签名未就绪」是两件不同的事,措辞在 summary 里分开写。 + +为什么不做成门禁:今天已经有四次「守卫假绿」的账(见 `packaging/README.md` §12 里那几条先例)。 +把一条**按定义今天必然失败**的检查做成 required,只会有两种结局 —— 要么所有人学会忽略红叉, +要么有人把它改成永远绿。真正的红留在 `release-publish.yml` 的签名步:那里今天红是**对的**, +因为签不出来的产物本来就不能发。 + +## 7. 本手册**没**覆盖 / 没验的部分(明账) + +* **Authenticode / SmartScreen**:项目自有签名解决的是「这条自更新链是不是我们发的」,用户双击 exe 看到 + 的「未知发布者」与此无关(`signing.md` §0)。 +* **托管签名**(如 SignPath):只是后续可替换路径,本手册与判据都不依赖它。 +* **本文档里的命令没有被真跑过一遍**:`--generate-key` 会产出生产私钥,塔不生成、也不跑它; + ②③ 两步要动 GitHub Secret 与 Rust 常量,同理。所以本手册是「按现有脚本的接口写的步骤」, + 首次执行时**以脚本的实际输出为准**,跑完请回来修正本文档。 +* **「用户机器上真的更新成功」**没有在本条里验:需要一次真 Release + 一台装着旧版本的机器。 + +## 8. 相关文件 + +| 文件 | 作用 | +|---|---| +| `packaging/check_signing_ready.py` | 三行就绪度 + `0/1/2` 退出码(只读、零第三方依赖) | +| `packaging/tests/signing_ready_selftest.py` | 判据自测:三行/退出码/两个方向的变异重放/接线不是门禁 | +| `.github/workflows/signing-status.yml` | 把三行搬进 job summary 的**播报** workflow(不是门禁) | +| `packaging/docs/signing.md` | 为什么签名、威胁模型、轮换纪律、四条发布侧命令的红法对照表 | +| `packaging/sign_manifest.py` | 生成钥 / 签名 / 对账 / 回验的唯一实现(`--self-test` 跑 RFC 8032 向量) | diff --git a/packaging/docs/signing.md b/packaging/docs/signing.md index d7962fe..9e24790 100644 --- a/packaging/docs/signing.md +++ b/packaging/docs/signing.md @@ -3,6 +3,12 @@ 本文件是**发布侧手册**:私钥从哪来、怎么用、怎么轮换、哪一步失败必须让发布变红。 用户面的说明(README 里「为什么第一个包要手动下」那类话)不在本文件范围。 +> **M255 补充:要照做的那几步在 [`signing-runbook.md`](signing-runbook.md)** —— 生成钥 → 私钥进 GitHub +> Secret(名字逐字是 `ULL_MANIFEST_SIGNING_KEY`)→ `PinnedKey` 贴进 `trust.rs` 标记之间 → 发一次带 `.sig` +> 的 Release → 用 `--verify-pinned` 回验 → 已知坑。本文件讲**为什么**,那份讲**怎么做**。 +> 现状读数用 `python3 packaging/check_signing_ready.py`(三行 + `0/1/2`;未就绪时会打印一行修复命令); +> CI 里同一条读数挂在 `.github/workflows/signing-status.yml` 的 job summary 上 —— **那是播报,不是门禁**。 + ## 0. 先说清这件事**不**解决什么 | 有 / 没有 | 状态 | diff --git a/packaging/tests/signing_ready_selftest.py b/packaging/tests/signing_ready_selftest.py new file mode 100644 index 0000000..9a0cb8c --- /dev/null +++ b/packaging/tests/signing_ready_selftest.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""`packaging/check_signing_ready.py` 的判据自测(M255):让「三行读数 + 0/1/2 退出码」变成可执行的事实。 + +为什么它必须存在:这个脚本的全部价值是「说清今天差什么」。它一旦退化成「永远 exit 0」, +它就从**播报**变成了本仓栽过四次的**假绿尺子** —— 而且没人会当场发现(输出看起来一模一样)。 +所以这里的第一类用例判的是退出码语义,第二类用例判的是**把退出码改绿之后自测会红**(变异重放)。 + +钉住的判据(每一条都对应 mission 的验收项): + +| 判据 | 用例 | 抓什么 | +|---|---|---| +| ① 表空 ⇒ `exit 1` 且三行都打印 | `empty_table` | 今天的状态(`PRODUCTION` 空表)必须读成「未就绪」,且不许少打印第 ③ 行 | +| ② 副本夹一条假 `PinnedKey` ⇒ `exit 0` | `pinned_and_key` | 就绪的正路真的存在(**不碰真实 `trust.rs`**:全部走 `--trust <临时副本>`) | +| 变异:把退出码改成永远 0 ⇒ 用例必须红 | `mutation_always_zero` | 上面 ① 的期望值不被「永远绿」满足 ⇒ 自测当场报红 | +| 三行之外的读数 | `key_mismatch` / `no_key` / `release_*` | ② 的对账(钥不在表里)、③ 的有/无/未知 | +| 不泄密 | `no_seed_leak` | 种子的十六进制在 stdout/stderr 里一次都不出现 | +| 用法错 = 2 | `markers_missing` / `usage` | 标记丢了、参数写错 ⇒ 2,不是 1 | +| 接线不是门禁 | `wiring` | 没有其它 workflow `uses:` 它;文件里没有 `::error::`;就绪度那步 `exit 0` | + +**不覆盖(如实记)**:本文件自己也是「守门人住在被守对象旁边」——整个 `signing-status.yml` 被删掉后 +没有任何东西会跑这份自测(与 `gpu_shot_ci_selftest.py` 同一条边界)。它也不判「用户机器上真的能更新」, +那是 `cargo test -p unlucky-core --lib update::tests::signature_cases` 与一次真 Release 的事。 + +公开的测试种子:`DUMMY_SEED` 是**由一句固定文本哈希出来的**假种子(同 `trust.rs` 里 `TEST_ONLY` 的那把 +公开测试钥同一路数),只用来造读数,签不出任何正式清单;文档与本文件都不含任何真实私钥。 + + python3 packaging/tests/signing_ready_selftest.py +""" + +from __future__ import annotations + +import hashlib +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +SCRIPT = ROOT / "packaging/check_signing_ready.py" +WORKFLOW_DIR = ROOT / ".github/workflows" +WORKFLOW = WORKFLOW_DIR / "signing-status.yml" +KEY_ENV = "ULL_MANIFEST_SIGNING_KEY" +FAIL_CLOSED = "这会让已安装用户永远验不过(fail-closed)" +LABELS = ("① 信任表 PRODUCTION", "② 环境签名钥", "③ 最近一次发布痕迹") + +sys.path.insert(0, str(ROOT / "packaging")) +from ed25519_ref import public_key # noqa: E402 + +DUMMY_SEED = hashlib.sha256(b"ull-m255-signing-ready-selftest-not-a-real-key").hexdigest() +DUMMY_PUB = public_key(bytes.fromhex(DUMMY_SEED)).hex() +OTHER_SEED = hashlib.sha256(b"ull-m255-selftest-the-other-dummy-key").hexdigest() + +TRUST_TEMPLATE = """\ +pub(crate) const PRODUCTION: &[PinnedKey] = &[ + // >>> PINNED-KEYS >>> +{body} // <<< PINNED-KEYS <<< +]; +""" + + +def secret_json(seed: str = DUMMY_SEED, key_id: str = "selftest-key") -> str: + return '{"key_id": "%s", "seed_hex": "%s"}' % (key_id, seed) + + +def write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="\n") + return path + + +def run(args: list[str], env_key: str | None, summary: Path | None = None, script: Path = SCRIPT) -> tuple[int, str]: + """跑一次脚本,返回 `(退出码, stdout+stderr)`。`env_key` = 塞进 `KEY_ENV` 的内容(`None` = 不设)。""" + env = {k: v for k, v in os.environ.items() if k != KEY_ENV} + env.pop("GITHUB_STEP_SUMMARY", None) + # 子进程与父进程的编码显式对齐成 UTF-8:本机 Windows 默认 gbk,中文读数会 decode 失败, + # 而 CI(ubuntu)本来就是 UTF-8。这条不是洁癖 —— 编码不一致会让整份自测在 Windows 上假红/假绿。 + env["PYTHONIOENCODING"] = "utf-8" + if env_key is not None: + env[KEY_ENV] = env_key + if summary is not None: + env["GITHUB_STEP_SUMMARY"] = str(summary) + done = subprocess.run( + [sys.executable, str(script), *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + cwd=str(ROOT), + check=False, + ) + return done.returncode, done.stdout + done.stderr + + +def case_empty_table(work: Path, failures: list[str], script: Path = SCRIPT) -> None: + """① 今天的状态:空表 ⇒ exit 1,三行都要打印,且必须点明 fail-closed 后果与修复命令。 + + `script` 可换:变异用例把**同一组断言**原样套在「退出码永远 0」的副本上,断言集合必须报红。 + """ + trust = write(work / "empty/trust.rs", TRUST_TEMPLATE.format(body="")) + rc, out = run(["--trust", str(trust)], None, script=script) + if rc != 1: + failures.append(f"判据①:空表应 exit 1,实际 {rc}\n{out}") + missing = [label for label in LABELS if label not in out] + if missing: + failures.append(f"判据①:空表时没打印这三行中的 {missing}\n{out}") + if FAIL_CLOSED not in out: + failures.append(f"判据①:未就绪时必须写明「{FAIL_CLOSED}」(后果,不是术语)\n{out}") + if "修复:" not in out: + failures.append(f"判据①:未就绪时必须给一行修复命令\n{out}") + + +def case_pinned_and_key(work: Path, failures: list[str], script: Path = SCRIPT) -> None: + """② 副本里夹一条假 PinnedKey + 环境里是同一把钥 ⇒ exit 0(真实 trust.rs 一个字节都不碰)。""" + body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n' + trust = write(work / "pinned/trust.rs", TRUST_TEMPLATE.format(body=body)) + rc, out = run(["--trust", str(trust)], secret_json(), script=script) + if rc != 0: + failures.append(f"判据②:表非空 + 钥同源应 exit 0,实际 {rc}\n{out}") + missing = [label for label in LABELS if label not in out] + if missing: + failures.append(f"判据②:就绪时也要打印三行,缺 {missing}\n{out}") + if "就绪" not in out: + failures.append(f"判据②:就绪时必须明说「就绪」\n{out}") + if FAIL_CLOSED in out: + failures.append(f"判据②:就绪时不该出现 fail-closed 那段警告\n{out}") + # 不泄密:种子的十六进制在输出里一次都不许出现(公钥可以出现,它是公开物)。 + if DUMMY_SEED in out: + failures.append(f"判据②:输出里出现了**种子**(私钥)—— 这是泄密\n{out}") + + +def case_key_mismatch(work: Path, failures: list[str]) -> None: + """② 的对账:表里钉的是 A,环境里是 B ⇒ 未就绪(发布时 --check-pinned-keyring 会红)。""" + body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n' + trust = write(work / "mismatch/trust.rs", TRUST_TEMPLATE.format(body=body)) + rc, out = run(["--trust", str(trust)], secret_json(OTHER_SEED, "other-key")) + if rc != 1: + failures.append(f"②对账:钥不在表里应 exit 1,实际 {rc}\n{out}") + if "不在" not in out: + failures.append(f"②对账:必须明说这把钥不在信任表里\n{out}") + if DUMMY_SEED in out or OTHER_SEED in out: + failures.append(f"②对账:输出里出现了种子\n{out}") + + +def case_no_key(work: Path, failures: list[str]) -> None: + """② 没有钥:即便表是好的,也算未就绪(签不出任何清单)。""" + body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n' + trust = write(work / "nokey/trust.rs", TRUST_TEMPLATE.format(body=body)) + rc, out = run(["--trust", str(trust)], None) + if rc != 1: + failures.append(f"②无钥:应 exit 1,实际 {rc}\n{out}") + if KEY_ENV not in out: + failures.append(f"②无钥:必须点名环境变量 {KEY_ENV}(换个人也知道该配什么)\n{out}") + + +def case_release_evidence(work: Path, failures: list[str]) -> None: + """③ 只取证:有留档 / 有清单没签名 / 什么都没给 = 未知;且它不影响退出码。""" + body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n' + trust = write(work / "release/trust.rs", TRUST_TEMPLATE.format(body=body)) + signed = work / "release/signed" + write(signed / "manifest.json", '{"channel": "stable", "releases": []}\n') + write(signed / "manifest.json.sig", '{"format": 1, "signatures": []}\n') + rc, out = run(["--trust", str(trust), "--dist", str(signed)], secret_json()) + if rc != 0 or "有 ——" not in out: + failures.append(f"③有:留档齐全时应写「有」且退出码仍是 0,实际 rc={rc}\n{out}") + + bare = work / "release/bare" + write(bare / "manifest.json", '{"channel": "stable", "releases": []}\n') + rc, out = run(["--trust", str(trust), "--dist", str(bare)], secret_json()) + if "没有" not in out: + failures.append(f"③无:只有 manifest.json 时必须点明「没有 .sig」\n{out}") + + nowhere = work / "release/absent" + rc, out = run(["--trust", str(trust), "--dist", str(nowhere)], secret_json()) + if "未知" not in out: + failures.append(f"③未知:取不到证据时必须写「未知」,不许写成「无」(本地没留档 ≠ 上次没签)\n{out}") + if rc != 0: + failures.append(f"③未知:③ 不参与就绪判定,退出码应仍是 0,实际 {rc}\n{out}") + + +def case_usage(work: Path, failures: list[str]) -> None: + """用法错 = 2:参数写错、标记丢了(脚本形不成判断)。绝不能混进 1(那是「未就绪」的读数)。""" + rc, out = run(["--nonsense"], None) + if rc != 2: + failures.append(f"用法错:未知参数应 exit 2,实际 {rc}\n{out}") + broken = write(work / "broken/trust.rs", "pub(crate) const PRODUCTION: &[PinnedKey] = &[];\n") + rc, out = run(["--trust", str(broken)], None) + if rc != 2: + failures.append(f"用法错:标记缺失应 exit 2(不是 1),实际 {rc}\n{out}") + rc, out = run(["--trust", str(work / "no-such-dir/trust.rs")], None) + if rc != 2: + failures.append(f"用法错:trust.rs 不存在应 exit 2,实际 {rc}\n{out}") + + +def case_summary(work: Path, failures: list[str]) -> None: + """`--line`:CI 用,把三行搬进 $GITHUB_STEP_SUMMARY;且必须写明「不是门禁」。""" + trust = write(work / "summary/trust.rs", TRUST_TEMPLATE.format(body="")) + summary = work / "summary-step.md" + rc, out = run(["--trust", str(trust), "--line"], None, summary=summary) + if rc != 1: + failures.append(f"summary:空表仍应 exit 1,实际 {rc}\n{out}") + if not summary.is_file(): + failures.append("summary:给了 $GITHUB_STEP_SUMMARY 却没写文件") + return + text = summary.read_text(encoding="utf-8") + missing = [label for label in LABELS if label not in text] + if missing: + failures.append(f"summary:三行没全部进 summary,缺 {missing}\n{text}") + if "不代表签名就绪" not in text: + failures.append(f"summary:必须写清「绿色不代表签名就绪」(播报 ≠ 门禁)\n{text}") + + +TARGET_LINE = " return 0 if ready else 1\n" + + +def mutant(work: Path, name: str, replacement: str) -> Path | None: + """造一份「只改了退出码那一行」的副本。副本自成一体(模块一起拷):脚本按 `__file__` 上一级找 + `packaging/`,不靠 PYTHONPATH(实测本机 3.12 根本不把 PYTHONPATH 放进 sys.path)。""" + source = SCRIPT.read_text(encoding="utf-8") + if source.count(TARGET_LINE) != 1: + return None + root = work / name + path = write(root / "packaging/check_signing_ready_mutated.py", source.replace(TARGET_LINE, replacement)) + for module in ("sign_manifest.py", "ed25519_ref.py"): + write(root / f"packaging/{module}", (ROOT / f"packaging/{module}").read_text(encoding="utf-8")) + write(root / "trust.rs", TRUST_TEMPLATE.format(body="")) + return path + + +def case_stray_hint(work: Path, failures: list[str]) -> None: + """① 的「标记之外还有条目」提示:只**点名**,不替人断言「贴错了」。 + + 实测抓到的误报:真实 `trust.rs` 里 `TEST_ONLY` 那一条**本来就在标记之外**(那是它的正确位置), + 上一版措辞「另有 N 条写在标记之外(贴错位置,等于没钉)」会对今天的状态喊狼来了。 + """ + outside = 'pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey { key_id: "test-only-key", public_key_hex: "%s" }];\n' + trust = write( + work / "stray/trust.rs", + outside % ("ab" * 32) + TRUST_TEMPLATE.format(body=""), + ) + rc, out = run(["--trust", str(trust)], None) + if rc != 1: + failures.append(f"①提示:标记外有条目时表仍是空的 ⇒ 应 exit 1,实际 {rc}\n{out}") + if "test-only-key" not in out: + failures.append(f"①提示:标记之外的条目必须**点名**(否则人不知道贴漏了哪一条)\n{out}") + if "TEST_ONLY" not in out: + failures.append(f"①提示:必须说清 `TEST_ONLY` 本来就在标记之外(否则这就是一句误报)\n{out}") + + +def case_mutation(work: Path, failures: list[str]) -> None: + """变异重放:改坏被测对象,**同一组断言原样套上去必须报红**(两个方向都试)。 + + 这是本 mission 唯一认可的形状 —— 不是「我写了断言」,而是「把对象改坏,断言真的会红」。 + """ + # ── 变异 A:退出码永远 0(「未就绪」伪装成「就绪」)⇒ 判据① 必须红 ── + always_zero = mutant(work, "mutant-zero", " return 0\n") + if always_zero is None: + failures.append(f"变异 A:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`,变异无处下手(脚本形状变了就更新这里)") + else: + trust = work / "mutant-zero/trust.rs" + probe: list[str] = [] + case_empty_table(work / "mutant-zero", probe, script=always_zero) + # 副本必须真的「跑得起来、只有退出码变了」:崩溃(rc=2)也会让 probe 非空,那就变成假绿了。 + rc, out = run(["--trust", str(trust)], None, script=always_zero) + if rc != 0: + failures.append(f"变异 A:副本在空表上应返回 0(这就是变异本身),实际 {rc}\n{out}") + missing = [label for label in LABELS if label not in out] + if missing: + failures.append(f"变异 A:副本连三行读数都没打印(缺 {missing}),它不是在测退出码那一条\n{out}") + if not probe: + failures.append(f"变异 A:「退出码永远 0」的副本竟然让判据①全过 —— 断言抓不住改绿(副本空表返回 {rc})") + elif not any("exit 1" in item for item in probe): + failures.append(f"变异 A:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据①:{probe}") + + # ── 变异 B:退出码永远 1(「就绪」伪装成「未就绪」)⇒ 判据② 必须红 ── + # 反向也要试:只测一个方向的话,「就绪的正路根本不存在」这种回归仍然全绿。 + always_one = mutant(work, "mutant-one", " return 1\n") + if always_one is None: + failures.append(f"变异 B:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`") + else: + probe = [] + case_pinned_and_key(work / "mutant-one", probe, script=always_one) + if not probe: + failures.append("变异 B:「退出码永远 1」的副本竟然让判据②全过 —— 就绪的正路没被断言(假绿的反面)") + elif not any("exit 0" in item for item in probe): + failures.append(f"变异 B:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据②:{probe}") + + +def case_wiring(failures: list[str]) -> None: + """接线判据:它**只是播报**。别人 `uses:` 它 = 它成了某条线的必需检查 ⇒ 红。""" + if not WORKFLOW.is_file(): + failures.append(f"接线:{WORKFLOW} 不存在") + return + text = WORKFLOW.read_text(encoding="utf-8") + for phrase in ("播报,不是门禁", "不应被设为 required"): + if phrase not in text: + failures.append(f"接线:文件头必须写清「{phrase}」(不阻断要是可核的事实,不是靠没接线)") + if "::error::" in text: + failures.append("接线:播报 workflow 里不许出现 `::error::`(那会让人以为构建挂了,与播报定位冲突)") + if "rc=$?" not in text or "exit 0" not in text: + failures.append("接线:就绪度那一步必须抓 `rc=$?` 后 `exit 0`(未就绪不许让 job 变红)") + wired: list[str] = [] + for path in sorted(WORKFLOW_DIR.glob("*.y*ml")): + if path.resolve() == WORKFLOW.resolve(): + continue + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if re.match(r"^\s*uses:\s*", line) and WORKFLOW.name in line: + wired.append(f"{path.relative_to(ROOT).as_posix()}:{number}: {line.strip()}") + if wired: + failures.append( + "接线:有 workflow `uses:` 了 " + WORKFLOW.name + " ⇒ 播报变成了别人的必需检查:\n " + "\n ".join(wired) + ) + + +def main() -> int: + failures: list[str] = [] + with tempfile.TemporaryDirectory(prefix="ull-signing-ready-") as temp: + work = Path(temp) + case_empty_table(work, failures) + case_pinned_and_key(work, failures) + case_key_mismatch(work, failures) + case_no_key(work, failures) + case_release_evidence(work, failures) + case_stray_hint(work, failures) + case_usage(work, failures) + case_summary(work, failures) + case_mutation(work, failures) + case_wiring(failures) + + for line in failures: + print(f"FAIL {line}", file=sys.stderr) + print("signing-ready selftest:", "ok" if not failures else f"{len(failures)} 处失败") + return 0 if not failures else 1 + + +if __name__ == "__main__": + raise SystemExit(main())