Pin backend to the A01 final budget fixes #672
@@ -0,0 +1,109 @@
|
||||
# fix/guard-test-blindspot-coverage 任务卡
|
||||
|
||||
任务主记录:PR #671 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/671;状态:已完成(合入前闭合);owner:weichen
|
||||
|
||||
- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= 7a5c80eacb835bfc6e62b3557129ec299bbb5290
|
||||
- 工作区:/home/weichen/projects/Agent-System-Dev-wt-fix-guard-test-blindspot-coverage(worktree);分支:fix/guard-test-blindspot-coverage
|
||||
- 分支类型:standalone;root_target:main;immediate_target:main;accepting_requirements:false
|
||||
- 需求分流:verdict=NEW;selected_initiative=无;requirement_sha256=d069a788b17d9bf644b3c362332b1557d44ea9966a6e2aa8d670dbe426d7ba3b;triage_receipt=/tmp/triage-guard-blindspots-20261020.json;receipt_sha256=f5b6947b7b39a1318e36fd5ba9e837cc6c67087c565badcb6b8bff3743ae4e5c
|
||||
|
||||
- 授权与交付终点:加固 PR #664 遗留的两项——守护测试只覆盖管道末级(中段 `| head`、`awk exit`、`| read`、前缀化 grep 是盲区)与硬编码受管脚本清单;改为按 git ls-files 自动发现 + 覆盖任意非首级早退读取器,并修复因此暴露的脚本写法。按现行门禁完成 Review、PR、CI、合入与回收。不得改动脚本语义与退出码、不得新增豁免压制发现、不得改 Makefile/CI/子模块/规范文档。
|
||||
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
|
||||
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
|
||||
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标:`tests/test_shell_pipe_hazard.py` 从「只看管道末级 + 硬编码 6 个脚本」升级为「任意非首级早退读取器 + git ls-files 自动发现全部受管脚本」,并由 `READER_FAMILIES` 显式声明守护契约(7 族:grep -q/-m、head、sed q/Q、awk exit/nextfile、read、mapfile/readarray -n、while/until/select 循环),每族必有合成自检。随之修复被新检测暴露的脚本写法。
|
||||
|
||||
非目标:不改变任何脚本的行为、退出码与调用顺序;不新增豁免列表压制发现;不改 Makefile、CI、子模块 pin、共享规范子模块与文档;不追求穷尽 bash 全部构造(契约是已声明族全覆盖且可扩展,族外命令只作非阻塞观察)。
|
||||
|
||||
验收条件:(1) 守护测试全绿且可证伪;(2) 受管脚本自动发现(含无扩展名脚本与 docker/*.sh)且 0 误报 0 解析错误;(3) `make test-governance` 全绿;(4) 独立 Review 无未闭合阻塞项。
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。
|
||||
|
||||
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| guard-test-blindspot-coverage | 守护测试 + 4 处脚本写法 | 本仓 AGENT-EXECUTION.md | implementation | cursor, antigravity, pi-glm, pi, codex | 2(r1 scope-expansion:新发现的 `scripts/e2e-duties.sh` 不在允许路径,host 记录最小扩围理由后重发) | cursor / auto | success | success (`8803bea`) | false | `/tmp/guard-blindspot-impl-r2-20261020.json`(r1:`/tmp/guard-blindspot-impl-r1-20261020.json`) |
|
||||
| guard-test-blindspot-independent-review | `.task/reviews/2026-10-20-guard-test-blindspot-review.md` | 同上 | review | pi-glm, … | 1 | pi-glm | BLOCKED(B1/B2/B3 + 前缀) | NOT_RUN | false | `/tmp/guard-blindspot-review-r2-20261020.json` |
|
||||
| guard-test-blindspot-blockers | `tests/test_shell_pipe_hazard.py` | 同上 | repair | cursor, … | 1 | cursor | success | success (`22dd375`) | false | `/tmp/guard-blindspot-repair-r1-20261020.json` |
|
||||
| guard-test-blindspot-rereview | 复审(保留现场报告) | 同上 | review | pi-glm, … | 1 | pi-glm | BLOCKED(mapfile/readarray -n、read -n) | NOT_RUN | false | `/tmp/guard-blindspot-rereview-r1-20261020.json` |
|
||||
| guard-test-blindspot-blockers-r2 | `tests/test_shell_pipe_hazard.py` | 同上 | repair | cursor, … | 3(r2 连接停滞零产物;r3 单据文件名被收据覆盖) | cursor | success | success (`6ddbae0`) | false | `/tmp/guard-blockers-r2-receipt-20261020.json` |
|
||||
| guard-test-blindspot-final-review | 终审 | 同上 | review | pi-glm, … | 1 | pi-glm | BLOCKED(until 条件 / 循环体 exit;发现缺口 N2) | NOT_RUN | false | `/tmp/guard-final-review-receipt-20261020.json` |
|
||||
| guard-test-blindspot-closure | `tests/test_shell_pipe_hazard.py` | 同上 | implementation | cursor, … | 2(r1 缺终态记录零产物) | cursor | success | success (`fcdf57f`) | false | `/tmp/guard-closure-receipt2-20261020.json` |
|
||||
| guard-test-blindspot-closure-review | `.task/reviews/2026-10-20-guard-test-blindspot-closure-review.md` | 同上 | review | pi-glm, … | 1 | pi-glm | **PASS(无阻塞项)** | success (`0871681`) | false | `/tmp/guard-closure-review-receipt-20261020.json` |
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|
||||
|---|---|---|---|
|
||||
| 启动 | 7a5c80eacb835bfc6e62b3557129ec299bbb5290 | 7a5c80eacb835bfc6e62b3557129ec299bbb5290 | 2026-10-21T01:50:09Z |
|
||||
|
||||
## 证据(Evidence)
|
||||
|
||||
<!-- CMD="<命令>" make evidence 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->
|
||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
## 结果与验证
|
||||
|
||||
- 结果:守护测试从「末级 + 6 个硬编码脚本」升级为「任意非首级早退读取器 + git ls-files 自动发现 21 个受管脚本(含 `scripts/task`、`scripts/dm`、`scripts/agent-exec` 等无扩展名脚本与 `docker/entrypoint.sh`)」,并由 `READER_FAMILIES` 显式声明 7 族契约、每族必有合成自检。随之修复 4 处被新检测暴露的写法:`scripts/e2e-duties.sh:87`(`ps … | grep -Fq`)、`:128`(`curl … | head -c 160`)、`scripts/hooks/pre-commit:9`(`echo "$branch" | grep -qE`)、`scripts/svc.sh` 的 `pgver`(`postgres --version | awk … exit`)。
|
||||
- 验证:PASS——守护测试 12 项全绿;受管脚本扫描 0 误报 0 解析错误;`make test-governance` 526 项 OK(18 skipped);三轮独立评审的 6 个阻塞项(子壳/花括组包裹、awk nextfile、while read break、绝对路径与 exec 前缀、mapfile/readarray -n 与 read -n、until 条件读取与循环体 exit)逐一修复并经终审独立复核通过,运行时 141 实证随每族自检固化在测试里。
|
||||
- 影响与剩余项:脚本行为、退出码与调用顺序未变(评审用探针逐条比对输出与状态码,含 `pre-commit` 写路径守卫的 13 组分支名)。非阻塞:族外命令(`dd`、`jq -n`、`perl -ne`、`xargs -n1`)与 `sed -f/awk -f` 外部程序文件不在契约内,只作观察;守护契约以已声明族为界,新增族需同步更新 `READER_FAMILIES` 与自检。
|
||||
|
||||
## 合入与清理
|
||||
|
||||
- Review 记录:PASS(三轮独立会话 pi-glm / zai-coding-cn/glm-5.3-flash;首轮 BLOCKED(B1/B2/B3+前缀)、复审 BLOCKED(mapfile/readarray -n、read -n)、终审 BLOCKED(until 条件读取、循环体 exit、发现缺口 N2),逐项修复后收口复审 **PASS 无阻塞项**,head `08716814cee11bb95aca4ac88f137b4738cfe39f`;报告 `.task/reviews/2026-10-20-guard-test-blindspot-closure-review.md`;重放依据:host 独立跑守护测试、`make test-governance`、bash -n 与发现清单核对)
|
||||
- 冲突记录:NOT_RUN(交付前同步见同步记录,无冲突;业务差异限于守护测试、4 处脚本写法与任务记录)
|
||||
- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果)
|
||||
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
|
||||
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
|
||||
|
||||
<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->
|
||||
```json
|
||||
{
|
||||
"summary_schema": 1,
|
||||
"source": "SELF",
|
||||
"timestamp": "2026-10-21T01:49:15.151491+00:00",
|
||||
"command": "make precheck",
|
||||
"exit_code": 0,
|
||||
"cwd": "/home/weichen/projects/Agent-System-Dev",
|
||||
"before_head": "7a5c80eacb835bfc6e62b3557129ec299bbb5290",
|
||||
"after_head": "7a5c80eacb835bfc6e62b3557129ec299bbb5290",
|
||||
"branch": "main",
|
||||
"target_branch": "main",
|
||||
"target_head": "7a5c80eacb835bfc6e62b3557129ec299bbb5290",
|
||||
"root_target": "main",
|
||||
"immediate_target": "main",
|
||||
"before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"output_tail": [
|
||||
"[repo] branch=main head=7a5c80eacb",
|
||||
"[remote] origin/main=7a5c80eacb ahead=0 behind=0",
|
||||
"[tree] clean submodules_drift=0 uninit=0",
|
||||
"[worktrees] linked=5",
|
||||
"[cards] 已合入未闭合任务卡:feature-project-access-lifecycle.md——make task-close 回填归档(主检出运行)",
|
||||
"[initiatives] local cards (not authoritative): none",
|
||||
"[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)",
|
||||
"[facts] policy/facts.md 6 条事实均在有效期内",
|
||||
"[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md",
|
||||
"[R0] 7a5c80eacb(完整:7a5c80eacb835bfc6e62b3557129ec299bbb5290)",
|
||||
"[worktree] before-sync: recursive component checks passed",
|
||||
"[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch"
|
||||
],
|
||||
"output_sha256": "32e9decc6bf07878d091a5b8b295cb8608e094a6051ff8d11ff78562d7cfea24",
|
||||
"receipt_sha256": "b0fe1d383445981b0652c72931c207cc38a1dfde570c388a998412ce0bee180c",
|
||||
"receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/b0fe1d383445981b0652c72931c207cc38a1dfde570c388a998412ce0bee180c.json"
|
||||
}
|
||||
```
|
||||
| 同步(记录前对象) | 210f8f305238a0241ce69ab5943b52d8bc5bcc62 | 3828cb5f2ba9df2ad4572126ebc8341e667bad33 | 2026-10-21T04:54:27Z |
|
||||
|
||||
## 闭合记录(合入前折叠)
|
||||
|
||||
- 本卡在 PR #671(https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/671)合入前闭合并归档,随该 PR 一同合入。
|
||||
- 该 PR 的合并提交即本任务的落地记录。
|
||||
- 合入后无需回填,也无需单独的闭合 PR。
|
||||
| 同步(记录前对象) | 8f59b135ec27fc04e432583d70e1e1d0d6f25ea8 | 61f13889d9821b67c126cb848f669b8a0023defd | 2026-10-21T05:14:03Z |
|
||||
@@ -0,0 +1,91 @@
|
||||
# 收口复审:fix/guard-test-blindspot-coverage(B1/B2 修复后)
|
||||
|
||||
- 审查对象:`70c49470a6fdc0bb8bbe7339dafdc271b981d40a..HEAD`(HEAD=`fcdf57fdfc89041194c7ebd773ae8c27a12d5662`,经 `git rev-parse` 确认与分支 `fix/guard-test-blindspot-coverage` 尖端一致)。范围即单个提交 fcdf57f,`git diff --stat` 显示仅改 `tests/test_shell_pipe_hazard.py`(+195/−26);四处脚本改写与其余已闭合结论所涉文件不在本范围内
|
||||
- 前置报告:`.task/reviews/2026-10-20-guard-test-blindspot-final.md`(BLOCKED,B1=`until <reader>` 条件终止、B2=循环体 `exit` 含 select)
|
||||
- 审查身份:全新独立会话,未参与实现;只读审查,除本报告外未修改任何文件;合成脚本与探针全部位于 /tmp,仓库内无残留
|
||||
- 审查日期:2026-10-21
|
||||
|
||||
## 结论:PASS
|
||||
|
||||
上一份报告的两个阻塞项均已闭合且运行时实证 141;发现机制已按非阻塞建议 N2 扩到无扩展名托管脚本与 docker/*.sh(21 个受管脚本,0 解析错误、0 命中,ignored 陈旧克隆仍不可能入扫描);READER_FAMILIES 声明与实现一致、逐族自检仍在且 while 族扩为 4 条用例;放宽后对受管脚本无新误报/新漏检(守护测试全量 12/12 通过);awk 引号串误报已消除、mapfile 词序豁免有据注释。残留 4 项均为声明外/声明豁免面的非阻塞观察(见下),依本轮可证伪边界不构成阻塞。
|
||||
|
||||
---
|
||||
|
||||
## 重点核验(按本轮 5 项)
|
||||
|
||||
### 1. 上轮阻塞项闭合 — 通过(检测器报出 + 运行时 141 独立实证)
|
||||
|
||||
`_lex` 新增 `exit_out()`(循环帧链上命令位 `exit` 置 `broken`,遇 `)`/`$(` 帧即停——子壳内 exit 不外溢,与运行时一致)、`_until_condition_reads()`(`do` 时捕获 `found[cond_start:]` 为条件列表,`until` 条件含声明族读者即报,首级以 `!` 开头的列表跳过)、`LOOPS` 扩入 `select`。/tmp 独立探针(不复制套件断言;`set -euo pipefail` 下运行):
|
||||
|
||||
| 形态(`seq 1 200000 | <stage>`) | 检测器 | 运行时 |
|
||||
|---|---|---|
|
||||
| `until read -r l; do :; done`(B1 复测) | 报 | 141 |
|
||||
| `until IFS= read -rt 1 l; do :; done` | 报 | 141 |
|
||||
| `until (read -r l); do :; done`(新:条件读者在子壳内) | 报 | 141 |
|
||||
| `until /usr/bin/head -1; do :; done`(新:条件读者绝对路径) | 报 | 141 |
|
||||
| `until timeout 1 head -1; do :; done`(新:条件读者经包装) | 报 | 141 |
|
||||
| `until true && read -r l; do :; done`(新:读者在条件后继列表) | 报 | 141 |
|
||||
| `while read -r l; do exit; done` 与条件 `[ … ] && exit`(B2 复测) | 报 | 141 |
|
||||
| `select x in a b; do exit; done`(新:select 体无条件 exit) | 报 | 141 |
|
||||
| `while read -r l; do for x in a; do exit; done; done`(新:for 嵌套内 exit) | 报 | 141 |
|
||||
| `while read -r l; do { echo "$l"; exit; }; done`(新) | 报 | 141 |
|
||||
| `while read -r l; do case $l in *) exit ;; esac; done`(新) | 报 | 141 |
|
||||
|
||||
反例不报且正确:`(exit 1)`、`x=$(exit 1)`、`case` 体 `echo exit` 均不报且生产者 PIPESTATUS[0]=0(读满,无 SIGPIPE)——子壳/命令替换内 exit 本就不出循环,判定精确。`while` 条件含读者(`while read`/`while grep -q 1`)与 `until ! read`/`until ! grep -q 1` 不报且读满(生产者 0),与声明「条件失败即 EOF 才结束」「! 取反除外」一致。
|
||||
|
||||
### 2. 发现机制 — 通过(N2 闭合,0 解析错误、0 命中)
|
||||
|
||||
- `SCRIPT_GLOBS` → `SCRIPT_DIRS = ("scripts", "docker")` + `_is_shell_script`(`.sh` 直纳,否则按首行 `#!` 的 `\b(?:ba|da|k|z)?sh\b` 判定)。实测 `git ls-files` 目录级 34 个跟踪文件,shebang 过滤排除 13 个非 shell(`docker/Dockerfile`、`docker/README.md`、9 个 `*.py`(`#!/usr/bin/env python3`)、2 个 `*.go`),得 **21 个受管脚本**,含上轮缺口 `scripts/task`、`scripts/dm`、`scripts/agent-exec`、`docker/entrypoint.sh`;`docker/verify-env.sh`、`tests/load/collaboration/run_pgbench.sh` 仍在。
|
||||
- 逐个跑 `_lex` + `hazards`:**0 解析错误、0 命中**(与 `test_managed_scripts_have_no_early_exit_pipe_reader` 一致);`EXEMPT={}` 且过期条目守卫仍在。
|
||||
- ignored 陈旧克隆仍不入扫描:发现只经 `git ls-files`(只列跟踪文件),`.gitignore:9 runtime/`(:10 `runtime-backups/`)经 `git check-ignore` 复核;本 worktree 无 runtime/ 目录,测试仍断言无 `runtime/` 前缀。
|
||||
|
||||
### 3. READER_FAMILIES 声明 ↔ 实现一致 — 通过
|
||||
|
||||
- 循环族声明扩为「while/until/select 循环:循环内命令位的 break 出本循环、命令位的 exit(子壳内除外)、until 条件含上列读取器(! 取反除外)」,与 `LOOPS`、`break_out`、`exit_out`(`)`/`$(` 截断)、`_until_condition_reads`(`!` 跳过)逐条吻合;否定面声明(`while` 条件读者读满、`mapfile` 无 `-n` 读满)也与实现一致。
|
||||
- 前缀/包装清单:`PREFIXES={!,time,command,builtin,env,exec}` + 新 `WRAPPERS={nohup,timeout(−s/−k/−−signal/−−kill-after 带参、1 个时长操作数),nice(−n/−−adjustment),stdbuf(−i/−o/−e/…)}`,与声明「!/time/command/builtin/env/exec 前缀、nohup/timeout/nice/stdbuf 包装命令及其选项与 timeout 时长」一致;`_skip_wrapper_args` 在首操作数处停。新写法探针:`nohup timeout 30 head -1`(包装嵌套)、`timeout --signal=KILL 5 head -1`、`nice -n 5 /usr/bin/head -1` 均报且 141。
|
||||
- `test_declared_reader_families_each_flagged` 仍是逐族自检且加严:while 族扩为 4 条用例(break/exit/until-read/select-break)逐条 `subTest` 断言命中,缺族/空字段即失败;套件实测通过。
|
||||
|
||||
### 4. 放宽后无新误报/新漏检(受管脚本面) — 通过
|
||||
|
||||
- 守护测试全量:`PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_shell_pipe_hazard -v` → **Ran 12 tests, OK**(上轮 11 个 + 新增 `test_loop_family_and_wrappers_flagged_and_harmful`,后者对 until 条件读取/循环体 exit/select break/四种包装逐条断言命中 **且** bash 运行时 141,对 7 个读满写法断言 rc=0 且检测器不报)。
|
||||
- 受管脚本扫描(含新纳入 4 个):21 个全部 0 命中、0 解析错误,无新误报;新族内写法对受管脚本的漏检未发现(本轮 11 个新探针全报出)。
|
||||
- 范围内未触碰 scripts/ 与 docker/(diff 仅测试文件),上轮「四处改写语义等价」「已闭合族」结论无需重查、未发现失效迹象。
|
||||
|
||||
### 5. awk 引号串误报消除、mapfile 词序豁免有据 — 通过
|
||||
|
||||
- N1 闭合:`_awk_exits_early` 先对单引号程序内 awk 字符串字面量 `AWK_STRING → ""` 再查 `exit|nextfile`。实测 `awk '{print "exit"}'`、`awk 'END{print "nextfile"}'` 不报且 rc=0;真危害 `awk '{print "exit"; exit}'`、`awk '{nextfile}'` 仍报且 141——只剥字符串、不放过语句。
|
||||
- N4 闭合:`_mapfile_exits_early` 首操作数截断处新增注释「bash 内建在首个操作数处停止选项解析:`mapfile arr -n 1` 的 -n 不生效、读满输入,不是提前退出」;实测 `mapfile arr -n 1` rc=0(读满),豁免有据且与运行时相符。
|
||||
|
||||
## 非阻塞观察(依可证伪边界,均在声明外或声明豁免面)
|
||||
|
||||
- **O1**:`while read -r l; do : || exit; done` 被报(声明「命令位的 exit」按词法命中,含 `||` 后位),运行时 rc=0(`:` 恒真、exit 永不执行)——过度近似、安全方向;受管脚本无此写法(扫描 0 命中)。与上轮 awk 引号串同类,若未来想收,需区分 `&&`/`||` 条件语境。
|
||||
- **O2**:`until ! awk '{exit 1}'; do :; done` 不报、运行时 141——读者早退且非零退出,`!` 取反后条件首 iter 即成功、循环即止。声明明文「! 取反除外」,属声明豁免面的残留危害(构造性强),记录在案不改判定。
|
||||
- **O3**:`awk -v s="exit" '{print s}'` 仍误报(双引号 shell 参数中的 exit 不在 `SHELL_SQ` 剥离面),rc=0。上轮 N1 的残余同类面(上轮即未含此形),安全方向、受管脚本无触发。
|
||||
- **O4**:`seq 1 200000 | timeout 5`(无被包装命令的畸形用法)不报、rc=125——timeout 用法错误立即退出同样会使管道读取级消失,但声明语义是「透过包装命令找读者」,无读者可找;畸形用法,不属声明族。
|
||||
- **O5**(沿上轮 N3/N6/N7,本轮未重查亦未失效):算术 `$((…|…))` 拆级误报、族外读者(dd/jq/perl/xargs)、`sed -f`/`awk -f` 外部程序文件,均声明外。
|
||||
|
||||
## 可证伪性注入记录(约束要求)
|
||||
|
||||
本轮独立注入 11 个上轮与套件均未覆盖的新形态(B1 条件读者 4 变体、B2/select/嵌套 exit 5 变体、包装 2 变体),**全部被报出且运行时 141**;另注入 6 个读满写法(含 `(exit 1)`、`x=$(exit 1)`、`until ! grep -q 1`),全部不报且生产者读满(PIPESTATUS[0]=0)。检测器对注入并非照单全收:声明外形态(O1 误报、O2/O4 漏报)如实暴露,失败集中在「`!` 豁免面」与「词法过度近似」两处,均为声明已划界处,不构成族内漏检。
|
||||
|
||||
## 已运行的验证命令与结果(均只读;合成物全部位于 /tmp)
|
||||
|
||||
| # | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | `git rev-parse HEAD fix/guard-test-blindspot-coverage` | 二者同为 fcdf57fd…;`git log --oneline 70c4947..HEAD` 仅 fcdf57f;`git diff --stat` 范围内仅 `tests/test_shell_pipe_hazard.py` +195/−26 |
|
||||
| 2 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_shell_pipe_hazard -v` | **Ran 12 tests, OK** |
|
||||
| 3 | `git ls-files -- scripts docker …`(旧 glob 与新目录两版对比)+ `_is_shell_script` 差集 | 旧 17 个 → 新 21 个;13 个非 shell(py/go/Dockerfile/README)被 shebang 过滤排除,首行内容逐一核对 |
|
||||
| 4 | Python 遍历 21 个受管脚本 `_lex`+`hazards` | errors=[],findings=[] |
|
||||
| 5 | `git check-ignore -v runtime/x`;`grep -n runtime .gitignore` | `.gitignore:9 runtime/`(:10 runtime-backups/);worktree 无 runtime/ 目录 |
|
||||
| 6 | /tmp 探针矩阵(见第 1、3 节表)+ `set -euo pipefail` 运行时 | 11 危害形态全报且 141;`(exit 1)`/`x=$(exit 1)`/`select :`/`until ! grep -q 1`/`until ! read` 不报且生产者 0 |
|
||||
| 7 | awk/mapfile/timeout 边界探针 | `{print "exit"}`、`END{print "nextfile"}` 不报 rc=0;`{print "exit"; exit}`、`{nextfile}` 报且 141;`mapfile arr -n 1` rc=0;`timeout 5` rc=125 不报(O4) |
|
||||
| 8 | `git status`(收尾) | 除本报告外无任何修改 |
|
||||
|
||||
第 6 项首轮执行时我的探针脚本误用 `set -uo pipefail`(缺 `-e`),管道失败后脚本继续走 `echo` 致基线全 0——判定探针自身缺陷后改用与套件一致的 `set -euo pipefail` 重跑,上表数据以重跑为准(如实记录,含失败)。
|
||||
|
||||
## 未验证事项
|
||||
|
||||
- 未运行 CI、未做 REPLAY(本审查单元不要求);全部结论基于本地只读命令与 /tmp 合成探针。
|
||||
- O2 的 `until ! <读者>` 面仅实测 awk(非零退出)与 `until ! head -1`(timeout 120s 观察到死循环、未等其自然终止);`!` 面其余读者组合未逐一实测。
|
||||
- O3 仅实测 `-v s="exit"` 一种双引号传参形;同类其它传参形未穷举。
|
||||
- 上轮已闭合项(B1/B2 之外的族内写法、四处脚本改写等价性)按收口范围未重查,仅确认本轮 diff 未触及对应文件与逻辑(grep/sed/head/read/mapfile 判定函数无改动,awk 仅加字符串剥离且经反例确认不回归)。
|
||||
@@ -0,0 +1,137 @@
|
||||
# 最终独立审查:fix/guard-test-blindspot-coverage(终审)
|
||||
|
||||
- 审查对象:`7a5c80eacb835bfc6e62b3557129ec299bbb5290..HEAD`(分支 `fix/guard-test-blindspot-coverage`,HEAD=`6ddbae089f61dcda8d74d7f5980fb81631e30ac5`,经 `git rev-parse` 确认即分支尖端)
|
||||
- 涉及文件:`tests/test_shell_pipe_hazard.py`(+406/−44)、`scripts/e2e-duties.sh`(2 处)、`scripts/hooks/pre-commit`(1 处)、`scripts/svc.sh`(1 处)、任务卡与首轮评审文档(`.task/**`,host 豁免路径)
|
||||
- 审查身份:全新独立会话,未参与实现;只读审查,除本报告外未修改任何文件;合成脚本与探针全部位于 /tmp,仓库内无残留
|
||||
- 审查日期:2026-10-21
|
||||
|
||||
## 结论:BLOCKED
|
||||
|
||||
历次评审的全部阻塞项(首轮 B1 子壳/花括组包裹、B2 awk nextfile、B3 while read break、绝对路径与 exec 前缀;第二轮 mapfile/readarray -n 与 read -n)在当前 HEAD 已全部闭合并有运行时 141 实证;四处脚本改写语义等价、发现机制稳定、契约表述诚实。但按本轮判据第 (2) 条与可证伪约束,实测构造出 **2 个此前未覆盖、运行时确认 pipefail 下 141、检测器不报** 的 **while/until 已声明命令族内其它停止写法**:
|
||||
|
||||
1. `seq 1 200000 | until read -r l; do :; done`(条件 read 首行成功即终止循环,无 break);
|
||||
2. `seq 1 200000 | while read -r l; do exit; done` 及条件形 `[ "$l" = 7 ] && exit`(循环体以 exit 终止读取级子壳,无 break)。
|
||||
|
||||
按本轮约束「构造至少一个此前未覆盖的早退读取器形态……如报不出,作为阻塞项」,判 BLOCKED。不推翻已闭合项与等价性结论。
|
||||
|
||||
---
|
||||
|
||||
## 历次评审闭合核验(全部闭合,含独立复测)
|
||||
|
||||
| 历次阻塞项 | 闭合证据(本轮独立复测) |
|
||||
|---|---|
|
||||
| R1-B1 子壳/花括组包裹 `\| (head -1)`、`\| { head -1; }` | 检测器报 `(1,'(head -1)')`/`(1,'{ head -1; }')`;pipefail 实测 141。且帧设计自然覆盖组内嵌套管道:`seq \| (cat \| head -1)`、`seq \| { cat \| head -1; }`、`seq \| ( ( head -1 ) )` 均报,实测均 141 |
|
||||
| R1-B2 awk nextfile | `awk '$1 == 7 { print; nextfile }'` 报;141 实测(套件 test_wrapped_and_prefixed… 内含 bash 运行时断言) |
|
||||
| R1-B3 while read + break(含 break N、brace 内 break) | `while read -r l; do break; done`、`do break 2; done`(嵌套 for)、`do { break; }; done` 均报,实测 141;`do (break); done` 不报且实测 rc=0——子壳 break 本就不出外层循环,判定正确(无误报) |
|
||||
| R1 绝对路径 / exec 前缀 | `\| /usr/bin/grep -q 1`、`\| exec grep -q 1`、`\| exec /usr/bin/head -1`、`\| /usr/bin/env LC_ALL=C grep -q 1` 均报 |
|
||||
| R2 mapfile/readarray -n | `-n1`、`-t -n 3`、`-tn2`、`-u 0 -n 1`、`--n 1`、`--numlines 1`、`builtin mapfile -n 1`、`{ mapfile -n 1 arr; }` 均报;套件对 `-n 1`/`read -n 5` 附带 bash 运行时 141 断言并通过 |
|
||||
| R2 read -n | `read -n 5 -r line`、`read --chars 5 line` 均报(read 族「任意用法」) |
|
||||
|
||||
## 重点核验(按本轮 5 项)
|
||||
|
||||
### 1. READER_FAMILIES 七族自检与族外不误报 — 通过
|
||||
|
||||
- `READER_FAMILIES`(tests/test_shell_pipe_hazard.py:23)7 族非空三元组;`test_declared_reader_families_each_flagged`(:548)以 `cases` 字典逐族断言「声明族必有被命中合成用例」,缺族/空字段即失败——契约↔测试 linkage 成立,套件 11/11 通过。
|
||||
- 族外安全写法不误报:套件 33 行安全清单通过;本轮追加探针 `seq \| (cat)`、`seq \| env FOO=1 command cat`、`seq \| LC_ALL=C grep -c 1`、`seq \| mapfile -t arr`、out-of-family 读者 `dd`/`jq -n`/`perl -ne`/`xargs -n1` 全部不报。两处既有安全方向误报(awk 引号串 "exit"、算术按位或)见 N1/N3,均不涉当前受管脚本。
|
||||
|
||||
### 2. 族内同族写法漏检 — 发现 2 个阻塞项(见下),其余族闭合
|
||||
|
||||
- grep:`_grep_exits_early`(:336)**不因操作数停扫**(非选项词仅跳过),故 `grep 1 -q`、`grep 1 --quiet`、`grep 1 -m1`、`grep -e 1 -q`、`grep -f file -q 1`、`grep -d skip -q 1` 全部报;grep 无 -q/-m 之外的提前退出选项。族内无漏检。
|
||||
- sed:`[qQ][0-9]*` 覆盖 `q`/`Q`/`qN`(`sed 'q2'` 实测即带退出码 2 的立即退出,被报);GNU sed 无其它停止命令。词法不可见的 `-f progfile` 见 N7。
|
||||
- awk:END 块剥离后查 `exit|nextfile`,BEGIN 内 exit 报、仅 END 内不报(读满输入,安全)——与声明一致。
|
||||
- head/read:任意用法全报。
|
||||
- mapfile:`-n` 簇内/独立/长选项全覆盖;词序倒置 `mapfile arr -n 1` 不报但 bash 内建 getopt 停于首操作数、`-n` 不生效,实测 rc=0 读满输入——惰性写法,非危害(N4)。
|
||||
- **while/until:除 break 外仍有两种停止写法漏检 → B1/B2 阻塞。**
|
||||
|
||||
### 3. 发现机制与受管脚本扫描 — 通过
|
||||
|
||||
- `managed_scripts()`(:51)经 `git ls-files -z -- ':(glob)scripts/**/*.sh' ':(glob)scripts/hooks/*' + 2 个 EXTRA`,本轮实测发现 17 个文件(含 3 个无扩展名 hooks);`scripts/hooks/pre-commit` 无扩展名仍被 `:(glob)scripts/hooks/*` 命中。
|
||||
- 稳定性:`_lex`+`hazards` 显式跑遍 17 个脚本,0 解析错误、0 命中(与 `test_managed_scripts_have_no_early_exit_pipe_reader` 一致);`EXEMPT={}` 且有过期条目守卫(:613)。
|
||||
- ignored 陈旧克隆不入扫描:`git ls-files` 只列跟踪文件 + `.gitignore:9` `runtime/`(`runtime-backups/:10` 同理);测试断言无 `runtime/` 前缀路径。本 worktree 无 runtime/ 目录,机制上也不可能漏入。
|
||||
- 非阻塞残留(N2,首轮已提、本轮复核仍在):`scripts/agent-exec`、`scripts/dm`、`scripts/task`(无扩展名 bash)与 `docker/entrypoint.sh` 不在 glob 范围;旧硬编码列表同样未含,无回归。
|
||||
|
||||
### 4. 四处脚本改写语义等价 — 通过(含写路径守卫)
|
||||
|
||||
探针(/tmp,同输入同状态对比,全部 SAME):
|
||||
|
||||
| 改写 | 状态覆盖 | 结果 |
|
||||
|---|---|---|
|
||||
| `ps … \| grep -Fq` → `grep -Fq < <(ps …)`(e2e-duties.sh:87 cleanup) | 活进程匹配(exec -a 伪造 argv)→ 双方 matched-kill;活进程不匹配 → no/no;死 PID → no/no | 三态等价 |
|
||||
| `curl … \| head -c 160` → `head -c 160 < <(curl …)`(e2e-duties.sh:128 log) | 1000B 取前 160B(165 字符含 `\|rc=` 完全一致)、短体、生产者失联 → 三态输出与 rc 等价(此处替换状态本就不被消费) | 等价 |
|
||||
| `echo "$branch" \| grep -qE` → `grep -qE <<<"$branch"`(pre-commit:9) | 13 组分支名(4 合法 + feat/x、feature/、feature//x、含空格、空串、大写、`fix/-n pwned`、换行名)退出码逐一相同 | 写路径守卫未削弱:合法分支照常 admit,非法照旧拒绝且文案不变;`git rev-parse --abbrev-ref` 的 detached="HEAD" 两侧同拒;here-string 尾随换行与 echo 一致 |
|
||||
| `postgres --version \| awk …exit` → `awk … < <(postgres --version) \|\| true`(svc.sh:4360 pgver) | 版本正常(24.1/24.1)、二进制失败(空/空)、空输出(空/空)、二进制缺失(空/空) | 捕获值与替换状态恒 0 两侧一致(`\|\| true` 保留) |
|
||||
|
||||
三脚本 `bash -n` 通过、均 `#!/usr/bin/env bash`(`<()`/`<<<` 合法);命令替换状态来源变化(head/awk 而非管道)在两处分别「不被消费」与「被 `\|\| true` 归零」,无行为差异。
|
||||
|
||||
### 5. 契约表述与实现一致 — 通过(无夸大承诺)
|
||||
|
||||
- docstring 明示「守护的承诺是『已声明的各族在发现到的脚本中处处被拦截』,而非『理解任意 bash 命令』」——承诺范围诚实收窄。
|
||||
- `READER_FAMILIES` 7 条描述与实现逐条吻合:grep -q/-m(含 egrep/fgrep、长选项与 `=`)↔ `_grep_exits_early`;head 任意 ↔ 短路 True;sed q/Q ↔ `SED_QUIT`;awk END 外 exit/nextfile(含 gawk/mawk/nawk 别名)↔ `_awk_exits_early`;read 任意 ↔ True;mapfile `-n/--n/--numlines`(含 readarray)↔ `_mapfile_exits_early`;while「循环体 break 出本循环(含 until)」↔ `_lex` loops + `break_out`(brace 透明、子壳 `)` 正确截断、break N 层级计数)。
|
||||
- 测试名与内容相符;「…flagged_and_harmful」用例真实执行 bash 并断言 141,非纸面断言。
|
||||
- 保留意见:while/until 族触发词限定为「break」是**如实**描述实现,但该族的其它停止写法(条件终止、体中 exit)落在声明触发词之外而命令族之内——正是本轮第 (2) 条要求排查的形态(见阻塞项)。
|
||||
|
||||
---
|
||||
|
||||
## 阻塞项
|
||||
|
||||
### B1 检测器漏检:`until` 循环条件 read 首行成功即终止(无 break)
|
||||
|
||||
- 形态:`seq 1 200000 | until read -r l; do :; done`。`until` 条件为 read:首行读取成功 → 循环即结束 → 读取级子壳退出、stdin 关闭。语义等价 `head -1` 的纯 bash 写法,属已声明 while/until 命令族的另一种停止写法(触发机制是条件成功而非 break)。
|
||||
- 运行时:`bash -c 'set -o pipefail; seq 1 200000 | until read -r l; do :; done'` → **141**(/tmp 实测)。
|
||||
- 检测器实测:`hazards(...)` → `[]`。`early_exit_reader`(:392)对 while/until 仅查 `_lex(s)[1]` 的 `broken`(break 置位),`break_out`(:113)只认 `break` 命令。
|
||||
- 影响:`until read`/`until IFS= read -t …` 形态可使缺陷类静默回流;当前 17 个受管脚本无此写法(`git grep "until "` 仅命中 ImageMagick `-until` 参数),属未来回归面。
|
||||
|
||||
### B2 检测器漏检:`while`/`until` 循环体内 `exit` 终止读取(无 break)
|
||||
|
||||
- 形态:`seq 1 200000 | while read -r l; do exit; done` 与惯用条件形 `… do [ "$l" = 7 ] && exit; done`。「读到目标行即退出」是常见习语;管道末级 while 在子壳中运行,exit 直接终止子壳、关闭 stdin。
|
||||
- 运行时:两形态 `set -o pipefail` 下均 **141**(/tmp 实测)。
|
||||
- 检测器实测:两形态均 `[]`——同 B1,`break_out` 不识别 `exit`。
|
||||
- 影响:同族「读满即停」的第二个入口,词法可静态识别(循环体内命令位的 `exit`),未覆盖即构成本轮可证伪条款下的阻塞。
|
||||
- 修复方向(供实施者参考,本轮未动代码):`break_out` 对循环帧内命令位的 `exit`/`exec <cmd>` 置 `broken`;`until` 循环在条件含声明族读者(read/head/…)时视为早退(`while` 条件读者则读满,安全,现判定已正确)。是否扩展触发词到 `return`(函数内管道级不可见,无意义)由实施者裁量。
|
||||
|
||||
两阻塞项均为「同一命令族(while/until)、运行时确认真实致害(141)、检测器不报」,满足本轮约束「如报不出,作为阻塞项」;亦满足首轮确立的「漏检使缺陷类可回流 = 阻塞」判据。合并修复面小(`break_out` + until 条件判定各一处),不影响其余已闭合结论。
|
||||
|
||||
## 非阻塞建议
|
||||
|
||||
- **N1(沿首轮)**:`seq 1 200000 | awk '{print "exit"}'` 被误报——`AWK_EXIT` 对程序文本裸 `\bexit\b`,引号串未剥离。安全方向;当前受管脚本无触发。建议 `_awk_exits_early` 剥引号串后再查。
|
||||
- **N2(沿首轮)**:发现缺口——`scripts/agent-exec`、`scripts/dm`、`scripts/task`(无扩展名 bash)与 `docker/entrypoint.sh` 不被 `:(glob)scripts/**/*.sh`/`:(glob)scripts/hooks/*`/EXTRA 命中。建议增补 `:(glob)scripts/*` 与 `:(glob)docker/*.sh`。
|
||||
- **N3(沿首轮)**:算术 `$((0|read))` 的 `\|` 被当管道拆级,操作数恰为 `read`/`head` 时误报。极牵强、安全方向,可忽略。
|
||||
- **N4**:`mapfile arr -n 1`(选项在操作数后)不报——bash 内建 getopt 停于首操作数,`-n` 不生效,实测 rc=0 读满输入,惰性无害;可加注释说明词序豁免理由。
|
||||
- **N5**:包裹前缀白名单 `PREFIXES`(:45)未含 `nohup`/`timeout`/`nice`/`stdbuf` 等 exec 型包装命令;`seq 1 200000 | timeout 5 head -1`、`| nohup head -1` 实测 141 且不报。属已声明「前缀」机制的可扩展清单之外(声明为「env/exec 等前缀」枚举),按可扩展契约记观察,不阻塞。
|
||||
- **N6(按可证伪边界记录)**:族外读者 `dd`(`| dd bs=1 count=1` 实测 141)、`jq -n`、`perl -ne 'exit if …'`、`xargs -n1`、`select` 循环(`| select x in a; do break; done` 实测 141)均不报——非 READER_FAMILIES 声明族,依本轮明确边界只记非阻塞观察。
|
||||
- **N7**:`sed -f prog.sed`/`awk -f prog.awk` 的程序文本在外部文件,词法不可见;与函数间接调用同级,契约「理解任意 bash 命令」之外,记观察。
|
||||
|
||||
## 判定标准对照
|
||||
|
||||
- 检测器漏检使该缺陷类可回流:**成立(B1/B2,运行时确认 141)→ 阻塞**。
|
||||
- 七族自检 + 族外不误报:成立(第 1 节)。
|
||||
- 发现机制稳定、无解析错误、ignored 陈旧克隆不入扫描:成立(第 3 节)。
|
||||
- 改写引入语义回归:未发现(4/4 等价,含写路径守卫 13 组分支名探针)。
|
||||
- 写守卫被削弱:未发现。
|
||||
- 契约夸大承诺:未发现(承诺已按「已声明族 + 已发现脚本」收窄);while/until 触发词如实标注 break,B1/B2 按同族停止写法计阻塞而非虚言。
|
||||
- 族外新命令族/构造:仅记 N5/N6/N7 非阻塞观察(边界条款)。
|
||||
|
||||
## 已运行的验证命令与结果(均只读;合成物全部位于 /tmp)
|
||||
|
||||
| # | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | `git rev-parse HEAD fix/guard-test-blindspot-coverage` | 二者同为 6ddbae089f61…;范围 `7a5c80e..HEAD` 即分支全量(diff --stat 6 文件) |
|
||||
| 2 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_shell_pipe_hazard -v` | **Ran 11 tests, OK** |
|
||||
| 3 | `git ls-files -z -- ':(glob)scripts/**/*.sh' ':(glob)scripts/hooks/*' docker/verify-env.sh tests/load/collaboration/run_pgbench.sh` | 17 文件(含 3 个 hooks 无扩展名脚本,无 runtime/) |
|
||||
| 4 | `head -1` + `bash -n`:scripts/e2e-duties.sh、scripts/hooks/pre-commit、scripts/svc.sh | 均 `#!/usr/bin/env bash`,bash -n 全过 |
|
||||
| 5 | /tmp 探针:检测器注入形态矩阵(grep 词序/参数选项、until-read、while-exit、组嵌套管道、mapfile 词序、sed qN、前缀包装、族外读者、$((算术\|))、awk 引号串) | 报:grep 全词序形态、组嵌套管道 `(cat\|head -1)`/`{ cat\|head -1; }`/`( (head -1) )`、`while :; do read && break; done`、`sed 'q…'`;**不报:until-read-nobreak、while+exit(B1/B2)**、`(break)`(正确,实测 0)、mapfile 词序(实测 0)、族外读者、nohup/timeout 前缀;误报:awk 'exit' 串、`$((0\|read))` |
|
||||
| 6 | /tmp pipefail 运行时基线(`bash -c 'set -o pipefail; …'`) | until-read、while+exit(无条件/条件)、`(cat\|head -1)`、`{ cat\|head -1; }`、`( (head -1) )`、`{ break; }`、`read && break`、`grep 1 -q`、`dd count=1`、`select …break`、`timeout 5 head -1`、`nohup head -1` → **141**;`while :`/`(break)`/`read \|\| break`/`mapfile arr -n 1`/`mapfile arr` → 0 |
|
||||
| 7 | /tmp 等价性探针(pre-commit 13 分支名、cleanup 3 态含真实匹配 PID kill、head -c160 3 态、pgver 4 态) | 全部 SAME |
|
||||
| 8 | 模块化扫描:`_lex`+`hazards` 遍历 17 个受管脚本 | errors=0,found=[] |
|
||||
| 9 | `git grep "until "`、`\| while` 于受管脚本 | 无 until 管道读者;7 处 `\| while` 循环体均读满输入(printf/continue),无 exit/break 于读取级 |
|
||||
| 10 | `grep -n runtime .gitignore`、`git check-ignore` 语义复核 | `.gitignore:9 runtime/`(:10 runtime-backups/),ignored 克隆不可能入 `git ls-files` |
|
||||
| 11 | `sed 'q2'`/`sed '5q2'`/`sed '5q 2'` 行为定性 | `q2` 即退出码 2 立即退出(检测器 `[qQ][0-9]*` 命中);空格形惰性(rc=0),不构成漏检危害 |
|
||||
|
||||
## 可证伪性注入记录(约束第 4 条)
|
||||
|
||||
本轮人为注入 5 组此前套件未覆盖的族内新写法:`grep 1 -q`(词序倒置)→ 报 ✓;`grep -e 1 -q`(参数选项后置)→ 报 ✓;`(cat | head -1)`(组内嵌套管道)→ 报 ✓;`until read -r l; do :; done` → **不报 ✗**;`while read -r l; do exit; done` → **不报 ✗**。检测器对注入并非全盘照收,失败集中于 while/until 停止写法一处机制(`break_out` 仅认 `break`),修复面集中。按约束,不报者计阻塞项。
|
||||
|
||||
## 未验证事项
|
||||
|
||||
- 本报告全部结论基于本地只读命令与 /tmp 合成探针;未运行 CI、未做 REPLAY(审查单元不要求)。
|
||||
- N5 中 `nice`/`stdbuf` 未逐个运行时实测(nohup/timeout 已实测 141),归类依同一 exec 包装机制推断。
|
||||
@@ -0,0 +1,124 @@
|
||||
# 独立代码审查:fix/guard-test-blindspot-coverage
|
||||
|
||||
- 审查对象:`7a5c80eacb835bfc6e62b3557129ec299bbb5290..8803bea966283996e4287a6f3b883d42f29b8e0b`(分支 `fix/guard-test-blindspot-coverage`,HEAD=8803bea)
|
||||
- 涉及文件:`tests/test_shell_pipe_hazard.py`、`scripts/e2e-duties.sh`、`scripts/hooks/pre-commit`、`scripts/svc.sh`(另有任务卡 `.task/fix-guard-test-blindspot-coverage.md`,属 host 豁免路径)
|
||||
- 审查身份:全新独立会话,未参与实现;只读审查,仅本报告文件为新增写产物
|
||||
- 审查日期:2026-10-20
|
||||
|
||||
## 结论:BLOCKED
|
||||
|
||||
变更整体是一次严格的覆盖面改进(4 处脚本改写全部语义等价、发现机制消除了硬编码、主流漏检形态已闭合、自检可证伪),但按本单元判定标准「检测器漏检使该缺陷类可回流 = 阻塞」,实测构造出 3 个**此前未被覆盖、运行时确认真实致害(pipefail 下 141)、检测器却不报**的早退读取器形态,其中 `awk nextfile` 属背景中点名的 awk 早退家族成员。故判 BLOCKED;不推翻已完成的等价性与发现机制结论。
|
||||
|
||||
---
|
||||
|
||||
## 阻塞项
|
||||
|
||||
### B1 检测器漏检:`| (head -1)` —— 子壳包裹的早退读取器不被报
|
||||
|
||||
- 形态:`seq 1 200000 | (head -1)`。`(` 走 `push_cmd(c)` 入帧(tests/test_shell_pipe_hazard.py:157),帧内 `head -1` 成为**单级管道**,而 `hazards()` 只检查 `stages[1:]`(tests/test_shell_pipe_hazard.py:289-298),故永不命中。
|
||||
- 运行时确认真实致害:`bash -c 'set -o pipefail; seq 1 200000 | (head -1) >/dev/null'` → exit **141**(/tmp/groundtruth.sh 实测)。
|
||||
- 检测器实测:`hazards('seq 1 200000 | (head -1)\n')` → `[]`(/tmp/hazard_matrix.py,`subshell-reader` 行:old=miss, new=miss)。
|
||||
- 影响:head/read/grep -q 全家族均可被一层子壳静默绕过,缺陷类可回流。
|
||||
|
||||
### B2 检测器漏检:`awk … nextfile` —— awk 家族成员不被报
|
||||
|
||||
- 形态:`seq 1 200000 | awk '$1==7{print; nextfile}'`。`_awk_exits_early` 只识别 `exit`(tests/test_shell_pipe_hazard.py:257-267),`nextfile` 同样会在读完输入前停止读取、关闭 stdin。
|
||||
- 运行时确认:`bash -c 'set -o pipefail; seq 1 200000 | awk "\$1==7{print; nextfile}" >/dev/null'` → exit **141**。
|
||||
- 检测器实测:`hazards(...)` → `[]`(矩阵行 `awk-nextfile`:old=miss, new=miss)。
|
||||
- 影响:背景(PR #664 遗留 a)明确点名 `awk … exit` 为盲区;`nextfile` 是同一 awk 早退家族的常用习语("取首条匹配即停"),修复后仍可无声回流。按判定标准构成阻塞。
|
||||
|
||||
### B3 检测器漏检:`| while read … break` —— 安全清单放行了带 break 的危险变体
|
||||
|
||||
- 形态:`seq 1 200000 | while read -r l; do break; done`。`early_exit_reader` 首词为 `while`,直接返回 False(tests/test_shell_pipe_hazard.py:270-288 无 while 分支)。
|
||||
- 运行时确认:`bash -c 'set -o pipefail; seq 1 200000 | while read -r l; do break; done'` → exit **141**。
|
||||
- 加重因素:新测试把 `seq 1 200000 | while read -r l; do :; done` 列入安全形态(tests/test_shell_pipe_hazard.py:347),未限定"循环体不含 break/exit",等于把危险变体也放行——违反"安全形态清单是否过宽以致把危险写法也放行"的关注点。
|
||||
- 附带同类(次要、非独立阻塞):`| /usr/bin/grep -q 1`(绝对路径前缀)与 `| exec grep -q 1` 因首词不在前缀剥离清单(tests/test_shell_pipe_hazard.py:273)而漏检,实测 141;词法检测器对动态派发有固有局限,但上述三种均为静态可识别形态。
|
||||
|
||||
---
|
||||
|
||||
## 已验证通过项(非阻塞)
|
||||
|
||||
### 1. 检测正确性(主流形态闭合)
|
||||
|
||||
新词法扫描(tests/test_shell_pipe_hazard.py:44-253 `pipelines`)实测判定可靠的形态:
|
||||
|
||||
| 形态 | 期望 | 实测 |
|
||||
|---|---|---|
|
||||
| heredoc 正文内的管道 | 不报 | 不报(正文按 `<<` 定界符跳过;`heredoc-body-hazard`、`heredoc-unterminated` 均 miss,属正确) |
|
||||
| heredoc 之后的真实管道 | 报 | 报(`heredoc-then-hazard` → [(4,'grep -q 1')]) |
|
||||
| case 模式中的 `\|` | 不报 | 不报(`case-pattern-pipe` miss,模式态由 cases 栈维护) |
|
||||
| case 体内管道读取器 | 报 | 报(`case-body-pipe` → FLAG) |
|
||||
| 续行后的管道 | 报 | 报(`cont-line-pipe` → [(2,'grep -q 1')]) |
|
||||
| `$(...)` 嵌套管道 | 报 | 报(`dq-nested-pipe` → [(1,'head -1')]) |
|
||||
| 反引号 | 报 | 报(`backtick-head` → [(1,'head -1`')],靠词法拆级碰巧覆盖) |
|
||||
| `$'...'` ANSI 引用 | 正常 | 正常(`ansi-quote` 内 `$(…\|head -1)` 仍报) |
|
||||
| 花括组读取器 `seq \| { head -1; }` | 报 | 报(`{` 在前缀剥离清单,tests/test_shell_pipe_hazard.py:273) |
|
||||
| 前缀化读取器 `LC_ALL=C grep -q`、`env … grep -m1` | 报 | 报(单测 tests/test_shell_pipe_hazard.py:324-325) |
|
||||
|
||||
对当前安全写法无误报(全 17 个受管脚本套件通过;矩阵误报候选仅 1,见 N1)。
|
||||
|
||||
### 2. 发现机制(相对旧硬编码严格变宽)
|
||||
|
||||
- `managed_scripts()` 经 `git ls-files -z`(tests/test_shell_pipe_hazard.py:28-33),实测发现 17 个文件,覆盖旧列表全部 6 个 + `scripts/check-pins.sh`、`scripts/evidence.sh`、`scripts/facts-verify.sh`、`scripts/fetch-rdm-pin.sh`、`scripts/hooks/{commit-msg,post-merge,pre-commit}`、`scripts/lint-architecture.sh`、`scripts/plugin-new.sh`、`scripts/zcode/write-guard.sh`。将来新增 `*.sh`/hooks 文件自动纳入,无需改测试。
|
||||
- `scripts/hooks/*` 被 `:(glob)scripts/hooks/*` 覆盖(含无扩展名脚本);`scripts/**/*.sh` 覆盖 `scripts/zcode/write-guard.sh` 与 `scripts/svc.sh`(零层目录也命中)。
|
||||
- runtime/ 陈旧克隆不会误扫:`git ls-files` 只列跟踪文件,`runtime/` 在 `.gitignore:9`(`runtime/`),测试断言 `p.startswith("runtime/")` 为空(tests/test_shell_pipe_hazard.py:366),实测通过。
|
||||
- `EXEMPT = {}`(tests/test_shell_pipe_hazard.py:17)恰当:无静默豁免文件;过期条目守卫(tests/test_shell_pipe_hazard.py:367-368)防止残留。
|
||||
- 遗留缺口见 N2(非阻塞)。
|
||||
|
||||
### 3. 自检有效性
|
||||
|
||||
- 新用例断言精确 `(行号, 级文本)`(tests/test_shell_pipe_hazard.py:317-331),词法任何漂移即失败,可证伪。新旧对照实测证明新用例针对真实盲区:`seq 1 200000 | { head -1; }`、`x=\`…\| head -1\``、管道中段 `head`、`awk exit`、`read` 等形态 old=miss → new=FLAG(/tmp/hazard_matrix.py)。
|
||||
- 全套件实测通过:`PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_shell_pipe_hazard -v` → Ran 6 tests, OK。
|
||||
- 反向注入验证(本审查构造):见阻塞项 B1/B2/B3——检测器对这三种新构造形态确实不报(这正是判 BLOCKED 的依据)。
|
||||
|
||||
### 4. 脚本改写语义等价(4/4 通过)
|
||||
|
||||
以 /tmp/equiv.sh 对旧/新写法做同输入探针(输出+退出码):
|
||||
|
||||
| 改写 | 位置 | 探针结果 |
|
||||
|---|---|---|
|
||||
| `ps … \| grep -Fq` → `grep -Fq < <(ps …)` | scripts/e2e-duties.sh:87 | 活 PID/死 PID/匹配 PID 三态新旧一致(no/no、yes/yes);`<()` 状态不入判定,`-F -q --` 参数不变 |
|
||||
| `curl … \| head -c 160` → `head -c 160 < <(curl …)` | scripts/e2e-duties.sh:128 | 短体(abc)、长体(1000B 取前 160B)、生产者失败三态新旧一致(含 `st=0`,替代捕获状态取 head 而非管道,此处状态本就未被消费) |
|
||||
| `echo "$branch" \| grep -qE` → `grep -qE <<<"$branch"` | scripts/hooks/pre-commit:9 | 9 组分支名(合法/非法/空/含空格/`-n pwned`/含引号)退出码逐一相同;here-string 尾随换行与 echo 一致;拒绝文案与 admit 后续执行不变 |
|
||||
| `"$pgbin/postgres" --version \| awk … exit \|\| true` → `awk … < <(postgres --version) \|\| true` | scripts/svc.sh:4360 | 正常版本(24.1)、二进制缺失(空)、非零退出(空)三态新旧捕获值一致;`pgver` 的 `\|\| true` 保留使替换状态恒 0,与旧 `管道 \|\| true` 等价;awk 提前退出改由 `<()` 吸收 SIGPIPE,不影响替换状态 |
|
||||
|
||||
三个被改脚本 `bash -n` 全部通过;均为 `#!/usr/bin/env bash`,`<()`/`<<<` 合法。
|
||||
|
||||
### 5. 范围与写守卫
|
||||
|
||||
- 变更范围:`git diff --stat` 仅 4 个目标文件 + 任务卡(`.task/**` 为 host 豁免路径),无越界。
|
||||
- `scripts/hooks/pre-commit` 属写路径守卫:改动仅把 `echo | grep` 换成等价 here-string(探针见上表),正则、拒绝分支、后续 `admit` 调用原样(scripts/hooks/pre-commit:16);shebang 为 bash,here-string 不引入 sh 兼容风险;守卫未被削弱。
|
||||
|
||||
---
|
||||
|
||||
## 非阻塞建议
|
||||
|
||||
- **N1 误报**:`seq 1 200000 | awk '{print "exit"}'` 被误报(`AWK_EXIT` 对 awk 程序文本做裸 `\bexit\b`,tests/test_shell_pipe_hazard.py:259,267),运行时实测 exit=0 安全。当前 17 个受管脚本无触发,仅造成未来开发摩擦(偏安全方向);建议 `_awk_exits_early` 像剥 END 块一样剥离引号串后再查 `exit`。
|
||||
- **N2 发现缺口(盲区 b 的残留)**:`scripts/` 下既有无扩展名 bash 脚本 `scripts/agent-exec`、`scripts/dm`、`scripts/task`(均 `#!/usr/bin/env bash`)不被 `:(glob)scripts/**/*.sh` 或 `:(glob)scripts/hooks/*` 命中;`docker/entrypoint.sh`(bash,`set -euo pipefail`)亦不在 EXTRA。旧硬编码列表同样未含它们,无回归、人工抽查未见多级管道读取器,但"新增受管脚本不被扫描"对这一命名惯例仍成立。建议增补 `:(glob)scripts/*`(及按需 `:(glob)docker/*.sh`);另 `:(glob)scripts/hooks/*` 不含 hooks 子目录。
|
||||
- **N3 词法小疵**:`$((a|b))` 算术中的 `|` 会被当作管道拆级(操作数非命令词故暂无误报;若变量名为 `head`/`read` 等且参与按位或可致误报)。可忽略。
|
||||
- **N4**:安全清单中 `while read` 条目建议加注释/用例说明"仅限循环体无 break/exit",与 B3 修复联动。
|
||||
|
||||
---
|
||||
|
||||
## 已运行的验证命令与结果(均只读;合成脚本全部位于 /tmp,仓库内无残留)
|
||||
|
||||
| # | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_shell_pipe_hazard -v` | Ran 6 tests, OK |
|
||||
| 2 | `git diff --stat 7a5c80e..8803bea` | 5 文件(4 目标 + 任务卡),+198/−33 |
|
||||
| 3 | `git ls-files -z -- ':(glob)scripts/**/*.sh' ':(glob)scripts/hooks/*' …` | 17 文件(含 3 个 hooks 无扩展名脚本,无 runtime/) |
|
||||
| 4 | 新旧检测器对照 `/tmp/hazard_matrix.py`(import 仓库模块与 `git show 7a5c80e:tests/…` 提取的旧模块,26 形态) | 新检 12 形态 FLAG;漏检 4(B1/B2/B3/绝对路径 grep);误报 1(N1);安全形态全部正确放行 |
|
||||
| 5 | 运行时基线 `/tmp/groundtruth.sh`(bash pipefail) | `(head -1)`、`awk nextfile`、`while break`、`/usr/bin/grep -q` → **141**;`mapfile`/`sed -n 1p`/`xargs -n1`/`awk '{print "x"}'` → 0 |
|
||||
| 6 | 等价性探针 `/tmp/equiv.sh`(pre-commit 9 分支名、cleanup 3 态、head -c160 4 态、pgver 4 态) | 全部新旧一致(1 行 DIFF 为探针自身期望值笔误,/bin/postgres 本机不存在,新旧同为空) |
|
||||
| 7 | `bash -n scripts/{e2e-duties,svc}.sh scripts/hooks/pre-commit` | 全部通过 |
|
||||
| 8 | 受管脚本 `nextfile`/`\| (`/`\| while`/`\| mapfile` 抽查(`git grep`) | 无 nextfile、无子壳读取器;6 处 `| while read` 循环体仅 `continue`(安全),1 处 `exit 2` 为函数用法错误分支 |
|
||||
| 9 | `head -1` 各被改脚本 / `git grep` 无扩展名 scripts/ 脚本 | shebang 均 bash;`scripts/{agent-exec,dm,task}` 为 bash 脚本(N2 依据) |
|
||||
| 10 | `git check-ignore -v runtime`、`.gitignore` | `.gitignore:9 runtime/`(ignore 生效,陈旧克隆不入 ls-files) |
|
||||
|
||||
## 判定标准对照
|
||||
|
||||
- 检测器漏检使该缺陷类可回流:**成立(B1/B2/B3,运行时确认 141)→ 阻塞**。
|
||||
- 误放行危险写法:安全清单 `while read` 未限 break 变体(B3 加重因素)。
|
||||
- 改写引入语义回归:未发现(4/4 等价)。
|
||||
- 写守卫被削弱:未发现。
|
||||
- 风格与可维护性:N1–N4,非阻塞。
|
||||
@@ -84,7 +84,7 @@ start_serve() {
|
||||
stop_serve() { [ -n "${API_PID:-}" ] && { kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; API_PID=""; }; }
|
||||
cleanup() {
|
||||
stop_serve
|
||||
if [ -n "${UI_PID:-}" ] && ps -p "$UI_PID" -o args= 2>/dev/null | grep -Fq -- "vite preview --port $UI_PORT"; then
|
||||
if [ -n "${UI_PID:-}" ] && grep -Fq -- "vite preview --port $UI_PORT" < <(ps -p "$UI_PID" -o args= 2>/dev/null); then
|
||||
kill "$UI_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
@@ -125,7 +125,7 @@ cmd() { # cmd <jar> <id> <params> → payload;rejected 即死
|
||||
|| die "command $2 rejected"
|
||||
}
|
||||
log "seed: host project + members + doc + duties + grant"
|
||||
log "me probe: $(curl -s -b "$AJ" "$API_BASE/api/me" | head -c 160) | jar=$(ls -la "$AJ" 2>/dev/null | awk '{print $5}')"
|
||||
log "me probe: $(head -c 160 < <(curl -s -b "$AJ" "$API_BASE/api/me")) | jar=$(ls -la "$AJ" 2>/dev/null | awk '{print $5}')"
|
||||
PIDRESP=$(curl -s -w '\n%{http_code}' -b "$AJ" -X POST "$API_BASE/api/projects" -H 'Content-Type: application/json' -d '{"name":"职责验收夹具"}')
|
||||
CODE=${PIDRESP##*$'\n'}; BODY=${PIDRESP%$'\n'*}
|
||||
[ "$CODE" = 200 ] || die "host project create: HTTP $CODE body=${BODY:0:200}"
|
||||
|
||||
@@ -6,7 +6,7 @@ case "$branch" in
|
||||
echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
|
||||
grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' <<<"$branch" || {
|
||||
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
+1
-1
@@ -4357,7 +4357,7 @@ db_create() {
|
||||
SVC_KIND=postgres
|
||||
SRC_LABEL="postgres@local"
|
||||
local pgver merged_tags
|
||||
pgver="$("$pgbin/postgres" --version 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+(\.[0-9]+)+$/) {print $i; exit}}' || true)"
|
||||
pgver="$(awk '{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+(\.[0-9]+)+$/) {print $i; exit}}' < <("$pgbin/postgres" --version 2>/dev/null) || true)"
|
||||
merged_tags="$(svc_merge_tags)"
|
||||
{
|
||||
printf 'kind=postgres\n'
|
||||
|
||||
+535
-40
@@ -1,39 +1,106 @@
|
||||
"""托管 shell 脚本:管道末级不得是提前退出的读取器(grep -q/-m、head、sed q)。
|
||||
"""托管 shell 脚本:管道任一读取级不得是提前退出的读取器(READER_FAMILIES 声明的各族:grep -q/-m、head、sed q、
|
||||
awk exit/nextfile、read、mapfile/readarray -n、以 break/exit 结束或 until 条件含读取器的 while/until/select 循环;
|
||||
含被子壳或花括组包裹、带绝对路径或 env/exec/nohup/timeout 等前缀的写法)。守护的承诺是“已声明的各族在发现到的
|
||||
脚本中处处被拦截”,而非“理解任意 bash 命令”。
|
||||
|
||||
pipefail 下读取器先退出,生产者写满 64 KiB 管道缓冲后收到 SIGPIPE,管道状态报 141,
|
||||
`if producer | grep -q PAT` 与 `if ! ...` 的判定被静默翻转。改用 here-string、进程替换
|
||||
或 sed 打印即退出,读取器的结论才不会丢。
|
||||
pipefail 下读取器先退出,上游生产者写满 64 KiB 管道缓冲后收到 SIGPIPE,管道状态报 141,
|
||||
`if producer | grep -q PAT` 与 `if ! ...` 的判定被静默翻转;读取器位于管道中段
|
||||
(`producer | head -1 | sed ...`)时同理。改用 here-string、进程替换或 sed 打印即退出,
|
||||
读取器的结论才不会丢。
|
||||
"""
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = (
|
||||
"scripts/svc.sh",
|
||||
"scripts/task.sh",
|
||||
"scripts/e2e.sh",
|
||||
"scripts/lint-boundaries.sh",
|
||||
"docker/verify-env.sh",
|
||||
"tests/load/collaboration/run_pgbench.sh",
|
||||
SCRIPT_DIRS = ("scripts", "docker")
|
||||
EXTRA_SCRIPTS = ("docker/verify-env.sh", "tests/load/collaboration/run_pgbench.sh")
|
||||
SHELL_SHEBANG = re.compile(r"#!.*\b(?:ba|da|k|z)?sh\b")
|
||||
# 发现到但检测器无法正确解析的脚本:路径 -> 理由。条目须仍被发现,否则视为过期。
|
||||
EXEMPT = {}
|
||||
|
||||
READER_FAMILIES = (
|
||||
("grep", "-q/--quiet/--silent、-m/--max-count(含 egrep/fgrep)", "得出匹配结论或读满 N 条匹配即退出"),
|
||||
("head", "任意用法", "输出前 N 行/字节后即退出"),
|
||||
("sed", "q/Q 命令", "执行 q/Q 即停止读取"),
|
||||
("awk", "END 块之外的 exit/nextfile(含 gawk/mawk/nawk)", "exit 立即结束,nextfile 放弃当前输入剩余部分"),
|
||||
("read", "任意用法(含 -n N/--chars N)", "只读一行,-n N 读满 N 个字符即返回"),
|
||||
("mapfile", "-n/-n<N>/-n <N>/--n/--numlines(含 readarray)", "读满 N 行即停止;长选项被 bash 拒绝时一字节不读即退出"),
|
||||
(
|
||||
"while",
|
||||
"while/until/select 循环:循环内命令位的 break 出本循环、命令位的 exit(子壳内除外)、until 条件含上列读取器"
|
||||
"(! 取反除外)",
|
||||
"break/exit 结束循环后不再读取剩余输入;until 条件读取成功即结束循环",
|
||||
),
|
||||
)
|
||||
"""守护覆盖的提前退出读取器族:(命令, 触发选项, 提前退出的原因)。
|
||||
|
||||
检测器保证这些族(含子壳/花括组包裹、VAR= 前缀、!/time/command/builtin/env/exec 前缀、nohup/timeout/nice/stdbuf
|
||||
包装命令及其选项与 timeout 时长、绝对路径写法)位于管道读取级时处处被拦截;不带触发选项的 mapfile/readarray、
|
||||
无 break/exit 的 while read、条件含读取器的 while(条件失败即 EOF 才结束)读完全部输入,不在拦截之列。
|
||||
"""
|
||||
|
||||
HEREDOC = re.compile(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2")
|
||||
SED_QUIT = re.compile(r"(?:^|[\s;{}'\"/$0-9])[qQ][0-9]*\s*(?:$|[\s;}'\"])")
|
||||
AWK_END = re.compile(r"\bEND\s*\{")
|
||||
AWK_EXIT = re.compile(r"\b(?:exit|nextfile)\b")
|
||||
AWK_STRING = re.compile(r'"(?:[^"\\\n]|\\.)*"')
|
||||
SHELL_SQ = re.compile(r"'[^']*'")
|
||||
ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
|
||||
WORD = re.compile(r"(\S+)\s*")
|
||||
BREAK_ARG = re.compile(r"[ \t]+([0-9]+)")
|
||||
PREFIXES = {"!", "time", "command", "builtin", "env", "exec"}
|
||||
# 包装命令:(带独立参数的选项, 被包装命令前的操作数个数);均在首个操作数处停止选项解析。
|
||||
WRAPPERS = {
|
||||
"nohup": (set(), 0),
|
||||
"timeout": ({"-s", "-k", "--signal", "--kill-after"}, 1),
|
||||
"nice": ({"-n", "--adjustment"}, 0),
|
||||
"stdbuf": ({"-i", "-o", "-e", "--input", "--output", "--error"}, 0),
|
||||
}
|
||||
LOOPS = ("while", "until", "select")
|
||||
GREP_ARG_OPTS = set("efABCdDX")
|
||||
MAPFILE_ARG_OPTS = set("dOsuCc")
|
||||
BOUNDARY = set(" \t\n;&|()<>")
|
||||
|
||||
|
||||
def _is_shell_script(rel):
|
||||
path = ROOT / rel
|
||||
if rel.endswith(".sh"):
|
||||
return True
|
||||
if not path.is_file():
|
||||
return False
|
||||
with path.open(encoding="utf-8", errors="replace") as f:
|
||||
return bool(SHELL_SHEBANG.match(f.readline()))
|
||||
|
||||
|
||||
def managed_scripts():
|
||||
"""git 跟踪的托管 shell 脚本(.sh 或首行 shell shebang);未跟踪副本(如 runtime/ 下的陈旧克隆)不在其中。"""
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(ROOT), "ls-files", "-z", "--", *SCRIPT_DIRS, *EXTRA_SCRIPTS],
|
||||
check=True, capture_output=True, text=True,
|
||||
).stdout
|
||||
return sorted(p for p in set(out.split("\0")) if p and _is_shell_script(p))
|
||||
|
||||
|
||||
class _Frame:
|
||||
"""一层命令上下文(顶层、$(...)、(...)、<(...))内正在累积的管道。"""
|
||||
"""一层命令上下文(顶层、$(...)、(...)、<(...)、管道级的 { ...; }、while/until/select 循环)内正在累积的管道。"""
|
||||
|
||||
def __init__(self, line):
|
||||
self.piped, self.stage, self.start, self.cases = False, [], line, []
|
||||
def __init__(self, line, depth, closer=None, kind=None, cond_start=0):
|
||||
self.stages, self.stage, self.start, self.cases = [], [], line, []
|
||||
self.depth, self.closer, self.dos, self.broken = depth, closer, 0, False
|
||||
self.kind, self.cond_start, self.cond = kind, cond_start, []
|
||||
|
||||
|
||||
def pipeline_tails(src):
|
||||
"""词法扫描 bash 源码,返回每条多级管道末级的 (起始行号, 文本);跳过注释与 heredoc 正文。"""
|
||||
tails, frames, modes, heredocs = [], [_Frame(1)], ["cmd"], []
|
||||
def _lex(src):
|
||||
"""词法扫描 bash 源码;跳过注释与 heredoc 正文。
|
||||
|
||||
返回 (lists, loops):lists 为每个命令列表的 (帧深度, [(起始行号, 级文本), ...]),
|
||||
loops 为每个 while/until/select 循环的 (帧深度, 循环关键字, 是否被 break/exit 结束, 条件内的 lists)。
|
||||
"""
|
||||
found, loops, frames, modes, heredocs = [], [], [_Frame(1, 0)], ["cmd"], []
|
||||
line, word, i, n = 1, "", 0, len(src)
|
||||
|
||||
def emit(s):
|
||||
@@ -41,27 +108,88 @@ def pipeline_tails(src):
|
||||
f.stage.append(s)
|
||||
|
||||
def finish(f):
|
||||
if f.piped:
|
||||
tails.append((f.start, "".join(f.stage).strip()))
|
||||
f.piped, f.stage, f.start = False, [], line
|
||||
text = "".join(f.stage).strip()
|
||||
if f.stages or text:
|
||||
found.append((f.depth, f.stages + [(f.start, text)]))
|
||||
f.stages, f.stage, f.start = [], [], line
|
||||
|
||||
def push(closer, kind=None):
|
||||
modes.append("cmd")
|
||||
frames.append(_Frame(line, len(frames), closer, kind, len(found)))
|
||||
|
||||
def pop():
|
||||
finish(frames[-1])
|
||||
f = frames.pop()
|
||||
modes.pop()
|
||||
if f.closer == "done":
|
||||
loops.append((f.depth, f.kind, f.broken, f.cond))
|
||||
|
||||
def case_state():
|
||||
cases = frames[-1].cases
|
||||
return cases[-1] if cases else None
|
||||
|
||||
def command_position(w):
|
||||
j = i - len(w)
|
||||
while j > 0 and src[j - 1] in " \t":
|
||||
j -= 1
|
||||
if j == 0 or src[j - 1] in ";\n&|(" or (src[j - 1] == ")" and case_state() == "body"):
|
||||
return True
|
||||
k = j
|
||||
while k > 0 and src[k - 1] not in BOUNDARY:
|
||||
k -= 1
|
||||
return src[k:j] in ("do", "then", "else", "{", "!")
|
||||
|
||||
def break_out(levels):
|
||||
for f in reversed(frames):
|
||||
nested = f.dos - (f.closer == "done" and f.dos > 0)
|
||||
if levels <= nested:
|
||||
return
|
||||
levels -= nested
|
||||
if f.closer == "done":
|
||||
f.broken = True
|
||||
levels -= 1
|
||||
if levels <= 0:
|
||||
return
|
||||
elif f.closer != "}":
|
||||
return
|
||||
|
||||
def exit_out():
|
||||
for f in reversed(frames):
|
||||
if f.closer == "done":
|
||||
f.broken = True
|
||||
elif f.closer != "}":
|
||||
return
|
||||
|
||||
def keyword(w):
|
||||
cases = frames[-1].cases
|
||||
top = frames[-1]
|
||||
cases = top.cases
|
||||
if w == "case":
|
||||
cases.append("in")
|
||||
elif w == "in" and case_state() == "in":
|
||||
cases[-1] = "pattern"
|
||||
elif w == "esac" and cases:
|
||||
cases.pop()
|
||||
elif case_state() == "pattern":
|
||||
pass
|
||||
elif w in LOOPS and "".join(top.stage).strip() == w:
|
||||
push("done", w)
|
||||
elif w == "do" and command_position(w):
|
||||
if top.closer == "done" and top.dos == 0:
|
||||
top.cond = found[top.cond_start :]
|
||||
top.dos += 1
|
||||
elif w == "done" and command_position(w):
|
||||
top.dos -= 1
|
||||
if top.closer == "done" and top.dos <= 0:
|
||||
pop()
|
||||
elif w == "break" and command_position(w):
|
||||
m = BREAK_ARG.match(src, i)
|
||||
break_out(int(m.group(1)) if m else 1)
|
||||
elif w == "exit" and command_position(w):
|
||||
exit_out()
|
||||
|
||||
def push_cmd(opener):
|
||||
def push_cmd(opener, closer=")"):
|
||||
emit(opener)
|
||||
modes.append("cmd")
|
||||
frames.append(_Frame(line))
|
||||
push(closer)
|
||||
|
||||
while i < n:
|
||||
c, nxt = src[i], src[i + 1 : i + 2]
|
||||
@@ -149,10 +277,15 @@ def pipeline_tails(src):
|
||||
elif c == ")":
|
||||
if case_state() == "pattern":
|
||||
top.cases[-1] = "body"
|
||||
elif len(frames) > 1:
|
||||
finish(top)
|
||||
frames.pop()
|
||||
modes.pop()
|
||||
elif top.closer == ")":
|
||||
pop()
|
||||
emit(c)
|
||||
i += 1
|
||||
elif c == "{" and not word and nxt in (" ", "\t", "\n") and top.stages and not "".join(top.stage).strip():
|
||||
push_cmd(c, "}")
|
||||
i += 1
|
||||
elif c == "}" and not word and top.closer == "}" and (nxt == "" or nxt in BOUNDARY) and command_position(""):
|
||||
pop()
|
||||
emit(c)
|
||||
i += 1
|
||||
elif c == "|":
|
||||
@@ -164,8 +297,9 @@ def pipeline_tails(src):
|
||||
emit(c)
|
||||
i += 1
|
||||
else:
|
||||
top.stages.append((top.start, "".join(top.stage).strip()))
|
||||
emit(c)
|
||||
top.piped, top.stage, top.start = True, [], line
|
||||
top.stage, top.start = [], line
|
||||
i += 2 if nxt == "&" else 1
|
||||
elif c == ";":
|
||||
if src.startswith(";;", i) or src.startswith(";&", i):
|
||||
@@ -211,7 +345,7 @@ def pipeline_tails(src):
|
||||
if (body.lstrip("\t") if dash else body) == term:
|
||||
break
|
||||
heredocs = []
|
||||
if not (top.piped and not "".join(top.stage).strip()):
|
||||
if not (top.stages and not "".join(top.stage).strip()):
|
||||
finish(top)
|
||||
emit("\n")
|
||||
else:
|
||||
@@ -220,10 +354,17 @@ def pipeline_tails(src):
|
||||
word += c
|
||||
i += 1
|
||||
|
||||
if word:
|
||||
keyword(word)
|
||||
if len(frames) != 1 or modes != ["cmd"]:
|
||||
raise ValueError(f"unbalanced shell lexing at EOF: modes={modes}")
|
||||
finish(frames[0])
|
||||
return tails
|
||||
return found, loops
|
||||
|
||||
|
||||
def pipelines(src):
|
||||
"""返回 bash 源码中每条多级管道各级的 [(起始行号, 文本), ...]。"""
|
||||
return [stages for _, stages in _lex(src)[0] if len(stages) > 1]
|
||||
|
||||
|
||||
def _grep_exits_early(words):
|
||||
@@ -243,23 +384,138 @@ def _grep_exits_early(words):
|
||||
return False
|
||||
|
||||
|
||||
def _mapfile_exits_early(words):
|
||||
"""mapfile/readarray 带 -n(-n<N>、-n <N>、选项簇内的 n)或 --n/--numlines 时只读 N 行即停止。"""
|
||||
skip = False
|
||||
for w in words[1:]:
|
||||
w = w.strip("'\"")
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
# bash 内建在首个操作数处停止选项解析:`mapfile arr -n 1` 的 -n 不生效、读满输入,不是提前退出。
|
||||
if w == "--" or not w.startswith("-") or len(w) == 1:
|
||||
break
|
||||
if w.startswith("--"):
|
||||
if w.split("=")[0] in ("--n", "--numlines"):
|
||||
return True
|
||||
continue
|
||||
for k, ch in enumerate(w[1:], 1):
|
||||
if ch == "n":
|
||||
return True
|
||||
if ch in MAPFILE_ARG_OPTS:
|
||||
skip = k == len(w) - 1
|
||||
break
|
||||
return False
|
||||
|
||||
|
||||
def _awk_exits_early(prog):
|
||||
"""awk 程序在 END 块之外含 exit 或 nextfile(含 BEGIN 内),即可能在读完输入前停止读取。
|
||||
|
||||
单引号程序内的 awk 字符串字面量先剥去(`print "exit"` 不是语句)。
|
||||
"""
|
||||
prog = SHELL_SQ.sub(lambda q: AWK_STRING.sub('""', q.group(0)), prog)
|
||||
while m := AWK_END.search(prog):
|
||||
depth, j = 0, m.end() - 1
|
||||
while j < len(prog):
|
||||
depth += {"{": 1, "}": -1}.get(prog[j], 0)
|
||||
j += 1
|
||||
if depth == 0:
|
||||
break
|
||||
prog = prog[: m.start()] + prog[j:]
|
||||
return bool(AWK_EXIT.search(prog))
|
||||
|
||||
|
||||
def _group_exits_early(group):
|
||||
"""( ... ) 或 { ...; } 内任一命令列表的首级(读组 stdin 者)是提前退出的读取器。"""
|
||||
end = group.rfind(")" if group[0] == "(" else "}")
|
||||
inner = group[1:end] if end > 0 else group[1:]
|
||||
return any(
|
||||
early_exit_reader(stages[0][1].lstrip(";&| \t\n")) for depth, stages in _lex(inner)[0] if depth == 0
|
||||
)
|
||||
|
||||
|
||||
def _skip_wrapper_args(cmd, s):
|
||||
"""跳过包装命令的选项(含带独立参数者)与被包装命令前的操作数,返回被包装命令起的文本。"""
|
||||
arg_opts, operands = WRAPPERS[cmd]
|
||||
opts = True
|
||||
while m := WORD.match(s):
|
||||
w = m.group(1)
|
||||
if opts and w == "--":
|
||||
opts = False
|
||||
elif opts and w.startswith("-") and len(w) > 1:
|
||||
if w in arg_opts:
|
||||
s = s[m.end() :]
|
||||
m = WORD.match(s)
|
||||
if not m:
|
||||
return ""
|
||||
elif operands:
|
||||
opts, operands = False, operands - 1
|
||||
else:
|
||||
return s
|
||||
s = s[m.end() :]
|
||||
return s
|
||||
|
||||
|
||||
def _until_condition_reads(cond):
|
||||
"""until 条件含读取器:首次读取成功即结束循环。! 取反的条件只在读取失败(EOF)时成立,与 while 同样读满输入。"""
|
||||
for _, stages in cond:
|
||||
texts = [text.lstrip(";&| \t\n") for _, text in stages]
|
||||
if texts[0].split(None, 1)[:1] == ["!"]:
|
||||
continue
|
||||
if any(early_exit_reader(text) for text in texts):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def early_exit_reader(stage):
|
||||
"""末级命令读到结论后是否会提前关闭 stdin(grep -q/-m、head、带 q 命令的 sed)。"""
|
||||
words = stage.split()
|
||||
if not words:
|
||||
"""读取级读到结论后是否会提前关闭 stdin(READER_FAMILIES:grep -q/-m、head、sed q、awk exit/nextfile、read、
|
||||
mapfile/readarray -n、以 break/exit 结束或 until 条件含读取器的 while/until/select)。
|
||||
|
||||
先剥去 VAR=value、!/time/command/builtin/env/exec 前缀与 nohup/timeout/nice/stdbuf 包装(连同其选项与 timeout
|
||||
时长)、取命令基名;子壳或花括组展开后逐条判定。
|
||||
"""
|
||||
s = stage.strip()
|
||||
while s:
|
||||
if s[0] in "({":
|
||||
return _group_exits_early(s)
|
||||
m = WORD.match(s)
|
||||
cmd = m.group(1).rsplit("/", 1)[-1]
|
||||
if not (ASSIGNMENT.match(m.group(1)) or cmd in PREFIXES or cmd in WRAPPERS):
|
||||
break
|
||||
s = s[m.end() :]
|
||||
if cmd in WRAPPERS:
|
||||
s = _skip_wrapper_args(cmd, s)
|
||||
if not s:
|
||||
return False
|
||||
cmd = words[0]
|
||||
if cmd in LOOPS:
|
||||
return any(
|
||||
broken or (kind == "until" and _until_condition_reads(cond))
|
||||
for depth, kind, broken, cond in _lex(s)[1]
|
||||
if depth == 1
|
||||
)
|
||||
words = [cmd, *s[m.end() :].split()]
|
||||
rest = " ".join(words[1:])
|
||||
if cmd in ("grep", "egrep", "fgrep"):
|
||||
return _grep_exits_early(words)
|
||||
if cmd == "head":
|
||||
if cmd in ("head", "read"):
|
||||
return True
|
||||
if cmd in ("mapfile", "readarray"):
|
||||
return _mapfile_exits_early(words)
|
||||
if cmd == "sed":
|
||||
return bool(SED_QUIT.search(stage.split(None, 1)[1] if len(words) > 1 else ""))
|
||||
return bool(SED_QUIT.search(rest))
|
||||
if cmd in ("awk", "gawk", "mawk", "nawk"):
|
||||
return _awk_exits_early(rest)
|
||||
return False
|
||||
|
||||
|
||||
def hazards(src):
|
||||
return [(line, text) for line, text in pipeline_tails(src) if early_exit_reader(text)]
|
||||
"""各管道第二级起(读 pipe 的各级)中提前退出的读取器,返回 (行号, 该级文本)。"""
|
||||
return [
|
||||
(line, text)
|
||||
for stages in pipelines(src)
|
||||
for line, text in stages[1:]
|
||||
if early_exit_reader(text)
|
||||
]
|
||||
|
||||
|
||||
class ShellPipeHazardTests(unittest.TestCase):
|
||||
@@ -280,6 +536,184 @@ class ShellPipeHazardTests(unittest.TestCase):
|
||||
with self.subTest(snippet=snippet):
|
||||
self.assertTrue(hazards(snippet + "\n"), snippet)
|
||||
|
||||
def test_detector_flags_newly_covered_shapes(self):
|
||||
for snippet, stage in (
|
||||
("seq 1 200000 | head -1 | sed 's/^/x/'", "head -1"),
|
||||
("x=\"$(seq 1 200000 | head -n 3 | tr -d ' ')\"", "head -n 3"),
|
||||
("seq 1 200000 | awk '{print $1; exit}'", "awk '{print $1; exit}'"),
|
||||
("seq 1 200000 | awk 'BEGIN { exit 1 }'", "awk 'BEGIN { exit 1 }'"),
|
||||
("seq 1 200000 | awk '/7/{f=1; exit} END{print f}' | cat", "awk '/7/{f=1; exit} END{print f}'"),
|
||||
("seq 1 200000 | read -r first", "read -r first"),
|
||||
("seq 1 200000 | IFS= read -r first", "IFS= read -r first"),
|
||||
("if seq 1 200000 | LC_ALL=C grep -q 1; then :; fi", "LC_ALL=C grep -q 1"),
|
||||
("seq 1 200000 | env LC_ALL=C grep -m1 1 | wc -l", "env LC_ALL=C grep -m1 1"),
|
||||
("seq 1 200000 | grep -q 1 | cat", "grep -q 1"),
|
||||
):
|
||||
with self.subTest(snippet=snippet):
|
||||
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
|
||||
|
||||
def test_wrapped_and_prefixed_readers_flagged_and_harmful(self):
|
||||
"""子壳/花括组、awk nextfile、带 break 的 while read、绝对路径与 exec 前缀:检测器命中,且 pipefail 下确实报 141。"""
|
||||
grep = shutil.which("grep") or "/usr/bin/grep"
|
||||
shapes = (
|
||||
("seq 1 200000 | (head -1)", "(head -1)"),
|
||||
("seq 1 200000 | ( head -1 )", "( head -1 )"),
|
||||
("seq 1 200000 | { head -1; }", "{ head -1; }"),
|
||||
("seq 1 200000 | awk '$1 == 7 { print; nextfile }'", "awk '$1 == 7 { print; nextfile }'"),
|
||||
("seq 1 200000 | while read -r l; do break; done", "while read -r l; do break; done"),
|
||||
(f"seq 1 200000 | {grep} -q 1", f"{grep} -q 1"),
|
||||
("seq 1 200000 | exec grep -q 1", "exec grep -q 1"),
|
||||
)
|
||||
with tempfile.TemporaryDirectory(dir="/tmp") as tmp:
|
||||
for snippet, stage in shapes:
|
||||
with self.subTest(snippet=snippet):
|
||||
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
|
||||
script = Path(tmp) / "shape.sh"
|
||||
script.write_text(f"set -euo pipefail\n{snippet}\necho survived\n", encoding="utf-8")
|
||||
run = subprocess.run(["bash", str(script)], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 141, run)
|
||||
self.assertNotIn("survived", run.stdout)
|
||||
|
||||
def test_detector_flags_group_loop_and_prefix_variants(self):
|
||||
for snippet, stage in (
|
||||
("seq 1 200000 | {\n head -1\n} | cat", "{\n head -1\n}"),
|
||||
("if seq 1 200000 | (grep -q 1); then :; fi", "(grep -q 1)"),
|
||||
("seq 1 200000 | (cd /tmp && head -1)", "(cd /tmp && head -1)"),
|
||||
("seq 1 200000 | while read -r l\ndo\n [ \"$l\" = 7 ] && break\ndone", "while read -r l\ndo\n [ \"$l\" = 7 ] && break\ndone"),
|
||||
("seq 1 200000 | until ! read -r l; do break; done", "until ! read -r l; do break; done"),
|
||||
("seq 1 200000 | while read -r l; do for x in a; do break 2; done; done", "while read -r l; do for x in a; do break 2; done; done"),
|
||||
("seq 1 200000 | /usr/bin/env LC_ALL=C grep -q 1", "/usr/bin/env LC_ALL=C grep -q 1"),
|
||||
("seq 1 200000 | exec /usr/bin/head -1", "exec /usr/bin/head -1"),
|
||||
("seq 1 200000 | until IFS= read -r l\ndo\n :\ndone", "until IFS= read -r l\ndo\n :\ndone"),
|
||||
("seq 1 200000 | until read -r a && read -r b; do :; done", "until read -r a && read -r b; do :; done"),
|
||||
("seq 1 200000 | until [ \"$(head -1)\" = 7 ]; do :; done", "until [ \"$(head -1)\" = 7 ]; do :; done"),
|
||||
("seq 1 200000 | while read -r l; do { exit 1; }; done", "while read -r l; do { exit 1; }; done"),
|
||||
("seq 1 200000 | while read -r l; do if [ -z \"$l\" ]; then exit 1; fi; done",
|
||||
"while read -r l; do if [ -z \"$l\" ]; then exit 1; fi; done"),
|
||||
("seq 1 200000 | while read -r l; do case $l in 7) break ;; esac; done",
|
||||
"while read -r l; do case $l in 7) break ;; esac; done"),
|
||||
("seq 1 200000 | select x in a; do [ -n \"$x\" ] && exit; done", "select x in a; do [ -n \"$x\" ] && exit; done"),
|
||||
("seq 1 200000 | timeout -s KILL 5 grep -q 1", "timeout -s KILL 5 grep -q 1"),
|
||||
("seq 1 200000 | timeout --foreground 5 head -1", "timeout --foreground 5 head -1"),
|
||||
("seq 1 200000 | env LC_ALL=C /usr/bin/timeout 5 head -1", "env LC_ALL=C /usr/bin/timeout 5 head -1"),
|
||||
):
|
||||
with self.subTest(snippet=snippet):
|
||||
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
|
||||
|
||||
def test_loop_family_and_wrappers_flagged_and_harmful(self):
|
||||
"""until 条件读取、循环内 exit、select break、nohup/timeout/nice/stdbuf 包装:检测器命中且 pipefail 下报 141;
|
||||
对应的读满输入写法不命中且照常完成。"""
|
||||
shapes = (
|
||||
"until read -r l; do :; done",
|
||||
"until grep -q 1; do :; done",
|
||||
"while read -r l; do exit; done",
|
||||
"while read -r l; do [ \"$l\" = 7 ] && exit; done",
|
||||
"while read -r l; do case $l in 7) exit ;; esac; done",
|
||||
"until false; do read -r l; exit; done",
|
||||
"select x in a; do break; done",
|
||||
"timeout 5 head -1",
|
||||
"nohup head -1",
|
||||
"nice -n 5 head -1",
|
||||
"stdbuf -oL grep -q 1",
|
||||
)
|
||||
safe = (
|
||||
"while read -r l; do :; done",
|
||||
"while grep -q 1; do :; done",
|
||||
"until ! read -r l; do :; done",
|
||||
# select 读到 EOF 时状态为 1;断言生产者未收 SIGPIPE。
|
||||
"select x in a; do :; done >/dev/null || [ \"${PIPESTATUS[0]}\" = 0 ]",
|
||||
"awk '{print \"exit\"}' >/dev/null",
|
||||
"mapfile arr -n 1",
|
||||
"timeout 5 cat >/dev/null",
|
||||
)
|
||||
with tempfile.TemporaryDirectory(dir="/tmp") as tmp:
|
||||
for shape in shapes:
|
||||
snippet = f"seq 1 200000 | {shape}"
|
||||
with self.subTest(snippet=snippet):
|
||||
self.assertEqual(hazards(snippet + "\n"), [(1, shape)], snippet)
|
||||
script = Path(tmp) / "shape.sh"
|
||||
script.write_text(f"set -euo pipefail\n{snippet}\necho survived\n", encoding="utf-8")
|
||||
run = subprocess.run(["bash", str(script)], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 141, run)
|
||||
self.assertNotIn("survived", run.stdout)
|
||||
script = Path(tmp) / "safe.sh"
|
||||
script.write_text(
|
||||
"set -euo pipefail\n" + "".join(f"seq 1 20000 | {s}\n" for s in safe) + "echo survived\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
run = subprocess.run(["bash", str(script)], capture_output=True, text=True)
|
||||
self.assertEqual((run.returncode, run.stdout), (0, "survived\n"), run)
|
||||
self.assertEqual(hazards(script.read_text(encoding="utf-8")), [])
|
||||
|
||||
def test_counted_readers_flagged_and_harmful(self):
|
||||
"""mapfile/readarray -n 与 read -n:检测器命中,且 pipefail 下脚本中止(读满 N 后退出报 141,长选项被拒报非零)。"""
|
||||
shapes = (
|
||||
("seq 1 200000 | mapfile -n 1 arr", "mapfile -n 1 arr", 141),
|
||||
("seq 1 200000 | readarray --numlines 1 arr", "readarray --numlines 1 arr", None),
|
||||
("seq 1 200000 | read -n 5 -r line", "read -n 5 -r line", 141),
|
||||
)
|
||||
with tempfile.TemporaryDirectory(dir="/tmp") as tmp:
|
||||
for snippet, stage, rc in shapes:
|
||||
with self.subTest(snippet=snippet):
|
||||
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
|
||||
script = Path(tmp) / "shape.sh"
|
||||
script.write_text(f"set -euo pipefail\n{snippet}\necho survived\n", encoding="utf-8")
|
||||
run = subprocess.run(["bash", str(script)], capture_output=True, text=True)
|
||||
self.assertNotEqual(run.returncode, 0, run)
|
||||
if rc is not None:
|
||||
self.assertEqual(run.returncode, rc, run)
|
||||
self.assertNotIn("survived", run.stdout)
|
||||
script = Path(tmp) / "safe.sh"
|
||||
script.write_text(
|
||||
"set -euo pipefail\nseq 1 200000 > in.txt\nmapfile arr < in.txt\nread -r line < in.txt\n"
|
||||
"seq 1 200000 | mapfile -t arr\necho \"survived ${#arr[@]} $line\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
run = subprocess.run(["bash", str(script)], capture_output=True, text=True, cwd=tmp)
|
||||
self.assertEqual((run.returncode, run.stdout), (0, "survived 200000 1\n"), run)
|
||||
self.assertEqual(hazards(script.read_text(encoding="utf-8")), [])
|
||||
|
||||
def test_detector_flags_counted_reader_variants(self):
|
||||
for snippet, stage in (
|
||||
("seq 1 200000 | mapfile -n1 arr", "mapfile -n1 arr"),
|
||||
("seq 1 200000 | mapfile -t -n 3 arr", "mapfile -t -n 3 arr"),
|
||||
("seq 1 200000 | readarray -tn2 arr", "readarray -tn2 arr"),
|
||||
("seq 1 200000 | mapfile -u 0 -n 1 arr", "mapfile -u 0 -n 1 arr"),
|
||||
("seq 1 200000 | readarray --n 1 arr", "readarray --n 1 arr"),
|
||||
("seq 1 200000 | builtin mapfile -n 1 arr", "builtin mapfile -n 1 arr"),
|
||||
("seq 1 200000 | { mapfile -n 1 arr; }", "{ mapfile -n 1 arr; }"),
|
||||
("seq 1 200000 | read --chars 5 line", "read --chars 5 line"),
|
||||
):
|
||||
with self.subTest(snippet=snippet):
|
||||
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
|
||||
|
||||
def test_declared_reader_families_each_flagged(self):
|
||||
"""READER_FAMILIES 是守护的契约:非空,且每个声明族至少有一个被检测器命中的合成用例。"""
|
||||
cases = {
|
||||
"grep": "seq 1 200000 | grep -q 1",
|
||||
"head": "seq 1 200000 | head -1",
|
||||
"sed": "seq 1 200000 | sed -n '/1/{p;q;}'",
|
||||
"awk": "seq 1 200000 | awk '{print; exit}'",
|
||||
"read": "seq 1 200000 | read -n 5 -r line",
|
||||
"mapfile": "seq 1 200000 | mapfile -n 1 arr",
|
||||
"while": (
|
||||
"seq 1 200000 | while read -r l; do break; done",
|
||||
"seq 1 200000 | while read -r l; do exit; done",
|
||||
"seq 1 200000 | until read -r l; do :; done",
|
||||
"seq 1 200000 | select x in a; do break; done",
|
||||
),
|
||||
}
|
||||
self.assertTrue(READER_FAMILIES)
|
||||
for family in READER_FAMILIES:
|
||||
command = family[0]
|
||||
with self.subTest(family=command):
|
||||
self.assertEqual(len(family), 3)
|
||||
self.assertTrue(all(family), family)
|
||||
self.assertIn(command, cases, "declared family lacks a synthetic case")
|
||||
snippets = cases[command] if isinstance(cases[command], tuple) else (cases[command],)
|
||||
for snippet in snippets:
|
||||
self.assertTrue(hazards(snippet + "\n"), snippet)
|
||||
|
||||
def test_detector_accepts_safe_forms(self):
|
||||
src = "\n".join((
|
||||
"set -euo pipefail",
|
||||
@@ -287,7 +721,45 @@ class ShellPipeHazardTests(unittest.TestCase):
|
||||
"grep -q 1 < <(seq 1 200000)",
|
||||
'grep -q x <<<"$v"',
|
||||
"x=\"$(sed -n '/^k=/{s///p;q;}' \"$f\")\"",
|
||||
"x=\"$(sed -n '/^k=/{s///p;q;}' < <(seq 1 200000))\"",
|
||||
"awk '{print $1; exit}' < <(seq 1 200000)",
|
||||
"head -c 160 < <(seq 1 200000)",
|
||||
"ps -eo comm | sed -n '1,12p'",
|
||||
"seq 1 200000 | sed -n '1,12p' | tr -d ' '",
|
||||
"seq 1 200000 | awk '{n++} END {exit !n}'",
|
||||
"seq 1 200000 | awk '$1 == 7 { f = 1 } END { exit !f }' && echo hit",
|
||||
"seq 1 200000 | while read -r l; do :; done",
|
||||
"seq 1 200000 | while read -r l; do [ \"$l\" = 7 ] && continue; done",
|
||||
"seq 1 200000 | while read -r l; do for x in a b; do break; done; done",
|
||||
"while read -r l; do break; done < <(seq 1 200000)",
|
||||
"seq 1 200000 | while grep -q 1; do :; done",
|
||||
"seq 1 200000 | until ! read -r l; do :; done",
|
||||
"seq 1 200000 | while read -r l; do (exit 1); done",
|
||||
"seq 1 200000 | while read -r l; do x=$(exit 1); done",
|
||||
"seq 1 200000 | while read -r l; do echo exit; done",
|
||||
"seq 1 200000 | while read -r l; do case $l in 7) echo exit ;; esac; done",
|
||||
"seq 1 200000 | select x in a; do :; done",
|
||||
"seq 1 200000 | until [ -n \"$x\" ]; do x=1; done",
|
||||
"seq 1 200000 | awk '{print \"exit\"}'",
|
||||
"seq 1 200000 | mapfile arr -n 1",
|
||||
"seq 1 200000 | timeout 5 cat",
|
||||
"seq 1 200000 | nohup cat",
|
||||
"seq 1 200000 | stdbuf -oL sed -n p",
|
||||
"until read -r l; do :; done < file",
|
||||
"seq 1 200000 | awk '{n++} END { nextfile }'",
|
||||
"seq 1 200000 | (cat)",
|
||||
"seq 1 200000 | { cat; }",
|
||||
"seq 1 200000 | env FOO=1 command cat",
|
||||
"seq 1 200000 | time builtin cat >/dev/null",
|
||||
"seq 1 200000 | LC_ALL=C grep -c 1",
|
||||
"mapfile arr < file",
|
||||
"read -r line",
|
||||
"read -n 5 -r line < file",
|
||||
"seq 1 200000 | mapfile arr",
|
||||
"seq 1 200000 | mapfile -t arr",
|
||||
"seq 1 200000 | readarray -u 0 -t arr",
|
||||
"seq 1 200000 | mapfile -d '' arr",
|
||||
"seq 1 200000 | mapfile -t -- arr",
|
||||
"case \"$w\" in --auth|--config) grep -q x <<<\"$w\" ;; esac",
|
||||
"cat <<'EOF'",
|
||||
"seq 1 200000 | grep -q 1",
|
||||
@@ -296,8 +768,31 @@ class ShellPipeHazardTests(unittest.TestCase):
|
||||
))
|
||||
self.assertEqual(hazards(src), [])
|
||||
|
||||
def test_managed_scripts_have_no_early_exit_pipe_tail(self):
|
||||
for rel in SCRIPTS:
|
||||
def test_discovery_covers_managed_scripts(self):
|
||||
found = managed_scripts()
|
||||
for rel in (
|
||||
"scripts/svc.sh",
|
||||
"scripts/task.sh",
|
||||
"scripts/e2e.sh",
|
||||
"scripts/e2e-duties.sh",
|
||||
"scripts/lint-boundaries.sh",
|
||||
"scripts/hooks/pre-commit",
|
||||
"scripts/task",
|
||||
"scripts/dm",
|
||||
"scripts/agent-exec",
|
||||
"docker/entrypoint.sh",
|
||||
*EXTRA_SCRIPTS,
|
||||
):
|
||||
self.assertIn(rel, found)
|
||||
for rel in ("scripts/evidence.py", "scripts/lint-imports.go", "docker/Dockerfile", "docker/README.md"):
|
||||
self.assertNotIn(rel, found)
|
||||
self.assertFalse([p for p in found if p.startswith("runtime/")], found)
|
||||
self.assertFalse(set(EXEMPT) - set(found), "stale EXEMPT entries")
|
||||
|
||||
def test_managed_scripts_have_no_early_exit_pipe_reader(self):
|
||||
for rel in managed_scripts():
|
||||
if rel in EXEMPT:
|
||||
continue
|
||||
with self.subTest(script=rel):
|
||||
path = ROOT / rel
|
||||
self.assertTrue(path.is_file(), f"{rel} missing")
|
||||
|
||||
Reference in New Issue
Block a user