修复: rc=2 那半个缝(降权构造 blind/unreadable 的上游锚点);顺带修掉它暴露出的**真 bug**:sha256sum 不在 PATH 时 UnboundLocalError 崩在打出 RESULT 之前

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-*` 残留。
This commit is contained in:
2026-09-18 05:40:38 +08:00
parent 6ee9902194
commit e99a657143
2 changed files with 137 additions and 18 deletions

View File

@ -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

View File

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