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 不等于期望值"的普通失败里 ——