修复: 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:
@ -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
|
||||
|
||||
@ -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 不等于期望值"的普通失败里 ——
|
||||
|
||||
Reference in New Issue
Block a user