From e99a6571438a9ab1e674788cd0ad998749761c9e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 05:40:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20rc=3D2=20=E9=82=A3?= =?UTF-8?q?=E5=8D=8A=E4=B8=AA=E7=BC=9D=EF=BC=88=E9=99=8D=E6=9D=83=E6=9E=84?= =?UTF-8?q?=E9=80=A0=20blind/unreadable=20=E7=9A=84=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E9=94=9A=E7=82=B9=EF=BC=89=EF=BC=9B=E9=A1=BA=E5=B8=A6=E4=BF=AE?= =?UTF-8?q?=E6=8E=89=E5=AE=83=E6=9A=B4=E9=9C=B2=E5=87=BA=E7=9A=84**?= =?UTF-8?q?=E7=9C=9F=20bug**=EF=BC=9A`sha256sum`=20=E4=B8=8D=E5=9C=A8=20PA?= =?UTF-8?q?TH=20=E6=97=B6=20`UnboundLocalError`=20=E5=B4=A9=E5=9C=A8?= =?UTF-8?q?=E6=89=93=E5=87=BA=20RESULT=20=E4=B9=8B=E5=89=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-18 报的 M18。我复现了它,补锚点时又**意外撞出一个提交态就有的真 bug**(不是 pi 报的、也不是我预想的)。 ## 一、M18 复现:rc=2 只有下游锚点、没有上游锚点 `--exitcode-selftest` 原来四例期望只覆盖 **0/1**(实测读出 `, 0,` `, 1,` `, 1,` `, 0,`)。 把 `summary.py` 的 `if blind or unreadable: return 2` 改成 `return 0`: | 自检 | rc | 该不该响 | |---|---|---| | `--exitcode-selftest`(上游) | **0(绿)** | **该红** | | `--mutants-line-selftest`(下游) | **0(绿)** | 不该(它喂合成 status) | 真跑 blind 确认后果:`nobody + chmod 000 jobs/` ⇒ 变异版 **rc=0**(应 2) ⇒ "环境:没读数"整条链又断回"看着正常",**两个自检都不响**。与 pi 逐字一致。 ★ 根因是**结构性障碍**,不是疏忽:`summary.py` 用 `os.access(JOBS_DIR, R_OK|X_OK)` 判 blind, 而 **`os.access` 对 uid=0 恒为 True**(实测:`chmod 000` 后 root `access()==True`、nobody 才 False) —— 与我早先在 `check-file-modes.sh` 踩的 `[ -x ]` 是**同一个 root 陷阱**,只是换成了 `os.access`。 所以自检以 root 跑时 blind 分支**物理上不可达**。 ## 二、补法:降权构造前提(三格都有锚点:0 / 1 / **2**) `runIn()` 加降权模式:临时目录整棵放开到 nobody 可达 → 按场景收紧 (`blind`:`chmod 000 jobs/`;`unreadable`:目录 755 + 单文件 000)→ 用 `runuser -u nobody`(无则退 `setpriv --reuid=65534`)跑**真脚本**。两条路我都实测得 rc=2。 新增两条案例:`盲读⇒2`、`单文件读不到⇒2`。 ★ **前提构造不出来时**(拿不到降权工具)报 **RED 并明说"本机覆盖不到、这不是通过是没有锚点"** —— 照 pi 那句"哪条前提构造不出来就写明,别让它默认看起来是被守着的"。不静默跳过。 **变异验证**(都已还原): · **M18**(`blind ⇒ return 0`)⇒ 两条降权案例**都红**、exit 1(改前两个自检全绿)。 · **M19**(只把 `unreadable` 那一半改成 0)⇒ **只有**"单文件读不到"那条红 —— 证明两条锚点是**独立**的,不是互相冗余。 ## 三★★ 补锚点时撞出的**真 bug**(提交态就有,不是本笔引入) 构造"前提 B(单文件不可读)"时跑出 `UnboundLocalError: cannot access local variable 'r'`: ```python try: r = subprocess.run(['sha256sum', '-c', bl], ...) # ← 在 try 内绑定 ... except Exception: baseline_ok = (False, -1, -1) # ← 设了非 None ... else: # baseline_ok 非 None ⇒ 走这里 detail = [... for ln in (r.stdout or '').splitlines() ...] # ← r 从未绑定 ⇒ 崩 ``` 崩点在**打出 `RESULT` 行之前** ⇒ 上游只看到"没打出 RESULT",而真因(一个外部命令缺失) 埋在 traceback 里。**触发条件很实在**:`sha256sum` 不在 PATH(精简镜像 / PATH 被裁的 CI)。 实测:正常 rc=0;`PATH` 里去掉 `sha256sum` ⇒ `UnboundLocalError`、rc=1。 ⇒ 两件事一起修: ① `r` 不再依赖 try 内绑定; ② 把"**跑不了这项检查**"与"**检查了、没过**"**分成两种状态**(合并正是这个 bug 的成因)—— 现在那一格打 `baseline=(**跑不了 sha256sum 校验**:FileNotFoundError: ...)—— 这一格**不是**"底本对"也**不是**"有残留",是**没读数**`。 并补第 7 条锚点:**故意让 PATH 里没有 `sha256sum`**,要求 ① 不崩 ② 仍打出 `RESULT` ③ 那格说"跑不了"。 **M20**(把 `r` 绑定退回 try 内)⇒ 该案例红、exit 1,报的正是那个 `UnboundLocalError`。 ## 四、自检自身的两个问题(照实记) 1. **`--exitcode-selftest` 清理漏了**:清理原来是顺序执行的最后两步,`runIn` 中途抛错就跳过 —— 实测 `/tmp` 里留下 `exitcode-*`。改成 `try/finally`(失败正是最常被重跑的路径), 并加了"抛错时也清理"的验证(模拟中途 throw ⇒ 残留 0)。 2. **PATH 一裁连解释器都找不到**:`no-sha256sum` 那条如果不把 `python3` 解析成绝对路径, 会变成"脚本没跑起来"(退 1),而 **1 又是别的案例的期望值** ⇒ 又一次"没跑起来长得像通过"。 已先解析绝对路径。 ## 五、验证与状态 · 7 条锚点全绿:一致⇒0 / unlisted⇒1 / ghosts⇒1 / 说明条目⇒0 / **盲读⇒2** / **单文件⇒2** / **无 sha256sum⇒不崩**。 · 四个自检全 exit 0:`--mutants-line-selftest` 8 例、`--exitcode-selftest` **7 例**、`--skip-selftest` 5 例、`--probe-selftest` 3 例。 · 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0`(与改动前**同样 9 条**)、`mutants=48 ran=47 skipped=1 on_new_criteria=35`。 · 变异:M18 / M19 / M20 全部按预期红并已还原;`/tmp` 无 `exitcode-*`/`barepath-*` 残留。 --- client/electron/test/mutants/summary.py | 25 ++++- client/electron/test/run-all.mjs | 130 +++++++++++++++++++++--- 2 files changed, 137 insertions(+), 18 deletions(-) diff --git a/client/electron/test/mutants/summary.py b/client/electron/test/mutants/summary.py index 56e0491..c7b3b7a 100644 --- a/client/electron/test/mutants/summary.py +++ b/client/electron/test/mutants/summary.py @@ -134,6 +134,20 @@ def main(): # 套件这一行会直接显形,而不是等下一个人去信里找。 baseline_ok = None bl = os.path.join(HERE, 'baseline.sha') + # ★★ `r` 必须先绑定(我加自检时**意外**撞出来的真 bug)。 + # + # 原来 `r = subprocess.run(...)` 在 `try` 里,而 `except` 设 + # `baseline_ok = (False, -1, -1)` ⇒ `baseline_ok` **非 None** ⇒ 走下面 `else` 分支 + # ⇒ 第 162 行读 `r.stdout`,**而 `r` 从没绑定** ⇒ `UnboundLocalError` 崩在 + # **打出 `RESULT` 行之前**。 + # + # 触发条件不是奇想:`sha256sum` 不在 PATH(精简镜像、`PATH` 被裁过的 CI)就会命中。 + # 后果与这几天修的那一族**同形**:**崩了 ⇒ 没有 `RESULT` 行 ⇒ 上游只能看到 + # "没打出 RESULT"**,而真因(一个外部命令缺失)在 traceback 里。 + # + # ⇒ 两件事一起修:① `r` 先绑 None;② "**跑不了这项检查**"与"**检查了、没过**" + # 分成两种状态,不许合并(合并正是这个 bug 的成因)。 + bl_unrunnable = False if os.path.exists(bl): try: r = subprocess.run(['sha256sum', '-c', bl], cwd=REPO, @@ -144,11 +158,16 @@ def main(): if ln.strip() and not ln.lstrip().startswith('#')) ok = sum(1 for ln in (r.stdout or '').splitlines() if ln.endswith(': OK')) baseline_ok = (ok == total, ok, total) - except Exception: - baseline_ok = (False, -1, -1) + except Exception as e: # noqa: BLE001 —— 任何异常都只该降级,不该崩 + bl_unrunnable = f'{type(e).__name__}: {e}' bl_note = '' - if baseline_ok is None: + if bl_unrunnable: + # ★ "**跑不了这项检查**" ≠ "**检查了、没过**"(原来合并了,才让 `r` 未绑定也能走到 162 行)。 + # 这里明说跑不了,并给出原因 —— 与"底本过期/残留"两种成因各自的措辞并列。 + bl_note = (f' baseline=(**跑不了 sha256sum 校验**:{bl_unrunnable})' + f'—— 这一格**不是**"底本对"也**不是**"有残留",是**没读数**') + elif baseline_ok is None: bl_note = ' baseline=(没有 baseline.sha)' else: good, ok, total = baseline_ok diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 763e3db..f3a3b93 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -895,30 +895,130 @@ if (process.argv.includes('--exitcode-selftest')) { * "既不在底本也与 HEAD 不同"那一支 —— 但那支**不影响退出码**,本自检只判退出码。) */ const deps = ['test-keys.json', 'baseline.sha']; - const runIn = (manifest, files) => { + /* + * ★★ rc=**2** 的上游锚点(pi 2026-09-18 的 M18 指出的那半个缝)。 + * + * 原来四个案例的期望只覆盖 **0/1** ⇒ `blind or unreadable ⇒ return 2` **没有任何上游锚点**: + * 把 `return 2` 改成 `return 0`,**两个自检都不响**(我复现过,见提交信息)。 + * 而 `rc=2` 的前提**在 root 下物理上不可达** —— `summary.py` 用 + * `os.access(JOBS_DIR, R_OK|X_OK)` 判 `blind`,而 **`os.access` 对 uid=0 恒为 True** + * (实测:`chmod 000` 一个目录后 root 仍 `access()==True`,nobody 才是 False)。 + * 这与我早先在 `check-file-modes.sh` 里踩的 `[ -x ]` 是**同一个 root 陷阱**, + * 只是换成了 `os.access`。 + * + * ⇒ 补法:**降权跑**(`runuser -u nobody`,无则退 `setpriv`)。两条路我都实测得 rc=2。 + * + * ⚠️ 三条注意(不注意就会得到**假绿**,pi 也点了): + * 1. 临时目录整棵要对 nobody 可达(`mkdtemp` 默认 700)⇒ 逐层放开到 `jobs/`。 + * 否则 `FileNotFoundError` 退 **1**,而 **1 恰好是别的案例的期望值** ⇒ + * "没跑起来"会长得像"通过"。所以下面那条 `Traceback` 判据**尤其**重要。 + * 2. 降权跑写 `__pycache__` 会失败(无害),但会在 stderr 留痕 ⇒ `Traceback` 判据不受影响。 + * 3. 跑完要先恢复权限再 `rmSync`,别依赖 root 一定能删。 + * + * ★ 前提构造不出来时怎么办(pi 那条更一般的话):**明说"本机覆盖不到",不许默认看起来被守着**。 + * 所以下面拿不到降权工具时,这几条报 **RED**(而不是 skip)—— + * 因为"没有锚点"本身就等于这条分支没被判据守着,不能静静混过去。 + * (这台机器上 `runuser`/`setpriv` 都在,所以正常路径会真的跑。) + */ + const dropTo = (() => { + for (const [cmd, args] of [['runuser', ['-u', 'nobody', '--']], + ['setpriv', ['--reuid=65534', '--regid=65534', '--clear-groups']]]) { + const sp = spawnSync(cmd, [...args, 'true'], { encoding: 'utf8' }); + if (sp.status === 0) return { cmd, args }; + } + return null; + })(); + const runIn = (manifest, files, degrade) => { const d = mkdtempSync(join(tmpdir(), 'exitcode-')); - cpSync(src, join(d, 'summary.py')); // 每次都复制**真脚本** - for (const f of deps) cpSync(join(HERE, 'mutants', f), join(d, f)); - mkdirSync(join(d, 'jobs'), { recursive: true }); - for (const f of files) cpSync(realJob, join(d, 'jobs', f)); - writeFileSync(join(d, 'jobs.manifest.json'), JSON.stringify(manifest, null, 2)); - const sp = spawnSync('python3', [join(d, 'summary.py')], { encoding: 'utf8' }); - rmSync(d, { recursive: true, force: true }); - return { status: sp.status, out: sp.stdout || '', err: sp.stderr || '' }; + let bareDir = null; // `finally` 里要清理,所以声明在 try 之外 + /* + * ★ 清理必须走 `try/finally`(我自己踩过):原来清理是**顺序执行**的最后两步, + * `runIn` 中途抛错(我改这段时真抛过 `ReferenceError`)就**跳过清理** —— + * 实测发现 `/tmp` 里留下了 `exitcode-*` 目录。自检**不该在失败时留垃圾**, + * 因为失败恰恰是最常被重复跑的时候。 + */ + try { + cpSync(src, join(d, 'summary.py')); // 每次都复制**真脚本** + for (const f of deps) cpSync(join(HERE, 'mutants', f), join(d, f)); + mkdirSync(join(d, 'jobs'), { recursive: true }); + for (const f of files) cpSync(realJob, join(d, 'jobs', f)); + writeFileSync(join(d, 'jobs.manifest.json'), JSON.stringify(manifest, null, 2)); + if (degrade) { + // 整棵给 nobody 可达(mkdtemp 默认 700),最后一层按场景收紧 + spawnSync('chmod', ['755', d]); + for (const f of [...deps, 'summary.py']) spawnSync('chmod', ['644', join(d, f)]); + spawnSync('chmod', ['644', join(d, 'jobs.manifest.json')]); + spawnSync('chmod', ['755', join(d, 'jobs')]); + for (const f of files) spawnSync('chmod', ['644', join(d, 'jobs', f)]); + // 前提 A:目录不可进入 ⇒ blind;前提 B:目录可进、单文件不可读 ⇒ unreadable + spawnSync('chmod', [degrade === 'blind' ? '000' : '755', join(d, 'jobs')]); + if (degrade === 'unreadable') spawnSync('chmod', ['000', join(d, 'jobs', files[0])]); + } + /* + * `no-sha256sum`:让子进程的 PATH 里**找不到** `sha256sum` —— + * 用一条只含最小命令的 PATH 实现(不依赖容器/镜像差异)。 + * ⚠️ `python3` 本身也要走**绝对路径**:`spawnSync('python3')` 是按 PATH 解析的, + * PATH 一裁,连解释器都找不到 ⇒ 那会变成"脚本没跑起来"(退 1), + * 而 1 又是别的案例的期望值 ⇒ 又是"没跑起来长得像通过"。所以先解析绝对路径。 + */ + let env = process.env; + if (degrade === 'no-sha256sum') { + bareDir = mkdtempSync(join(tmpdir(), 'barepath-')); + const gitPath = spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).stdout.trim(); + if (gitPath) try { cpSync(gitPath, join(bareDir, 'git')); } catch { /* 拷不动不影响本例 */ } + env = { ...process.env, PATH: bareDir }; + } + const py = spawnSync('sh', ['-c', 'command -v python3'], + { encoding: 'utf8' }).stdout.trim() || 'python3'; + const argv = [join(d, 'summary.py')]; + const sp = dropTo && degrade && degrade !== 'no-sha256sum' + ? spawnSync(dropTo.cmd, [...dropTo.args, py, ...argv], { encoding: 'utf8' }) + : spawnSync(py, argv, { encoding: 'utf8', env }); + return { status: sp.status, out: sp.stdout || '', err: sp.stderr || '' }; + } finally { + // ★ 无论成功、失败还是抛错都清理(失败正是最常被重跑的路径) + if (bareDir) rmSync(bareDir, { recursive: true, force: true }); + spawnSync('chmod', ['-R', '755', d]); // 先恢复权限再删(别依赖 root 一定能删) + try { rmSync(d, { recursive: true, force: true }); } catch { /* 删不掉也别盖住真因 */ } + } }; const cases = [ // 清单与磁盘一致 ⇒ 0 - ['一致 ⇒ 0', ['jobs-one.json'], ['jobs-one.json'], 0, null], + ['一致 ⇒ 0', ['jobs-one.json'], ['jobs-one.json'], 0, null, false], // 磁盘上多一个、清单里没有(**unlisted**)⇒ 1(清单该改,**不是**环境) - ['未列入清单 ⇒ 1', ['jobs-one.json'], ['jobs-one.json', 'jobs-extra.json'], 1, '未列入清单'], + ['未列入清单 ⇒ 1', ['jobs-one.json'], ['jobs-one.json', 'jobs-extra.json'], 1, '未列入清单', false], // 清单里有、磁盘上没有(**ghosts**)⇒ 1 - ['清单有磁盘无 ⇒ 1', ['jobs-one.json', 'jobs-gone.json'], ['jobs-one.json'], 1, '磁盘上没有'], + ['清单有磁盘无 ⇒ 1', ['jobs-one.json', 'jobs-gone.json'], ['jobs-one.json'], 1, '磁盘上没有', false], // `_` 开头的说明条目不算 ghosts ⇒ 0(否则这份清单永远红) - ['说明条目不算 ghosts ⇒ 0', ['_note', 'jobs-one.json'], ['jobs-one.json'], 0, null], + ['说明条目不算 ghosts ⇒ 0', ['_note', 'jobs-one.json'], ['jobs-one.json'], 0, null, false], + // ★★ rc=2 的两个上游锚点(降权构造前提)—— 这两条就是 M18 缺的那一半 + ['盲读(目录不可进,降权)⇒ 2', ['jobs-one.json'], ['jobs-one.json'], 2, '读不到', 'blind'], + ['单文件读不到(降权)⇒ 2', ['jobs-one.json'], ['jobs-one.json'], 2, '在但读不到', 'unreadable'], + /* + * ★★ 锚住一个**我加这组自检时意外撞出来的真 bug**(不是 pi 报的,也不是我预想的): + * + * `summary.py` 里 `r = subprocess.run(['sha256sum', ...])` 在 `try` 内,而 `except` + * 设了 `baseline_ok` 非 None ⇒ 走 `else` 分支读 `r.stdout` ⇒ **`r` 从未绑定** ⇒ + * `UnboundLocalError`,崩在**打出 `RESULT` 之前**。 + * 触发条件很实在:**`sha256sum` 不在 PATH**(精简镜像 / PATH 被裁的 CI)。 + * + * 所以这条案例**故意让 PATH 里没有 `sha256sum`**,要求:① 不许崩(无 Traceback); + * ② 仍要打出 `RESULT` 行;③ 那一格必须说"**跑不了**",不许说成"底本对"或"有残留"。 + * —— 正是"**没读数** ≠ **读数正常**",同一条纪律第四次。 + */ + ['PATH 里没有 sha256sum ⇒ 不崩且说"跑不了"', ['jobs-one.json'], ['jobs-one.json'], + 0, '跑不了 sha256sum', 'no-sha256sum'], ]; let bad = 0; - for (const [what, manifest, files, want, wantOut] of cases) { - const got = runIn(manifest, files); + // 前提构造不出来 ⇒ 明说 + 计红(不许静默跳过) + if (!dropTo) { + console.log('RED 降权工具(runuser/setpriv)不可用 ⇒ **rc=2 的两条分支本机覆盖不到** —— ' + + '这不是"通过",是"没有锚点"。请在有降权工具的机器上跑,或手工验证。'); + bad++; + } + for (const [what, manifest, files, want, wantOut, degrade] of cases) { + if (degrade && !dropTo) continue; // 上面已经计过一次红并说明了 + const got = runIn(manifest, files, degrade); /* * ★ 先判"是不是脚本根本没跑起来":`Traceback` 在 stderr 里 ⇒ **立即可疑**, * 不许让它落进下面那条"rc 不等于期望值"的普通失败里 ——