Files
Agent-System-Dev/tests/test_shell_pipe_hazard.py
T

807 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""托管 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 ! ...` 的判定被静默翻转;读取器位于管道中段
(`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]
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, 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 _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):
for f in frames:
f.stage.append(s)
def finish(f):
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):
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, closer=")"):
emit(opener)
push(closer)
while i < n:
c, nxt = src[i], src[i + 1 : i + 2]
mode = modes[-1]
if mode in ("sq", "ansi"):
if c == "\\" and mode == "ansi":
emit(src[i : i + 2])
i += 2
continue
emit(c)
line += c == "\n"
if c == "'":
modes.pop()
i += 1
continue
if mode in ("dq", "brace"):
if c == "\\":
emit(src[i : i + 2])
line += nxt == "\n"
i += 2
elif c == "$" and nxt == "(":
push_cmd("$(")
i += 2
elif c == "$" and nxt == "{":
emit("${")
modes.append("brace")
i += 2
elif c == '"':
emit(c)
if mode == "dq":
modes.pop()
else:
modes.append("dq")
i += 1
elif c == "}" and mode == "brace":
emit(c)
modes.pop()
i += 1
else:
emit(c)
line += c == "\n"
i += 1
continue
if c in BOUNDARY and word:
keyword(word)
word = ""
top = frames[-1]
if c == "\\":
if nxt == "\n":
emit(" ")
line += 1
else:
emit(src[i : i + 2])
word += "\0"
i += 2
elif c == "#" and (i == 0 or src[i - 1] in " \t\n;&|("):
j = src.find("\n", i)
i = n if j < 0 else j
elif c == "'" or (c == "$" and nxt == "'"):
emit(src[i : i + 2] if c == "$" else c)
modes.append("ansi" if c == "$" else "sq")
word += "\0"
i += 2 if c == "$" else 1
elif c == '"':
emit(c)
modes.append("dq")
word += "\0"
i += 1
elif c == "$" and nxt == "{":
emit("${")
modes.append("brace")
word += "\0"
i += 2
elif c == "$" and nxt == "(":
push_cmd("$(")
word += "\0"
i += 2
elif c == "(":
if case_state() == "pattern":
emit(c)
else:
push_cmd(c)
i += 1
elif c == ")":
if case_state() == "pattern":
top.cases[-1] = "body"
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 == "|":
if nxt == "|":
finish(top)
emit("||")
i += 2
elif case_state() == "pattern":
emit(c)
i += 1
else:
top.stages.append((top.start, "".join(top.stage).strip()))
emit(c)
top.stage, top.start = [], line
i += 2 if nxt == "&" else 1
elif c == ";":
if src.startswith(";;", i) or src.startswith(";&", i):
if case_state() == "body":
top.cases[-1] = "pattern"
k = 3 if src.startswith(";;&", i) else 2
else:
k = 1
finish(top)
emit(src[i : i + k])
i += k
elif c == "&":
if nxt == "&":
finish(top)
emit("&&")
i += 2
elif nxt == ">" or (i > 0 and src[i - 1] in "<>"):
emit(c)
i += 1
else:
finish(top)
emit(c)
i += 1
elif c == "<" and src.startswith("<<", i):
m = None if src.startswith("<<<", i) else HEREDOC.match(src, i)
if m:
heredocs.append((m.group(3), m.group(1) == "-"))
emit(m.group(0))
i = m.end()
else:
k = 3 if src.startswith("<<<", i) else 2
emit(src[i : i + k])
i += k
elif c == "\n":
line += 1
i += 1
for term, dash in heredocs:
while i < n:
j = src.find("\n", i)
body = src[i : n if j < 0 else j]
i = n if j < 0 else j + 1
line += 1
if (body.lstrip("\t") if dash else body) == term:
break
heredocs = []
if not (top.stages and not "".join(top.stage).strip()):
finish(top)
emit("\n")
else:
emit(c)
if c not in BOUNDARY:
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 found, loops
def pipelines(src):
"""返回 bash 源码中每条多级管道各级的 [(起始行号, 文本), ...]。"""
return [stages for _, stages in _lex(src)[0] if len(stages) > 1]
def _grep_exits_early(words):
for w in words[1:]:
w = w.strip("'\"")
if w == "--":
break
if w.startswith("--"):
if w.split("=")[0] in ("--quiet", "--silent", "--max-count"):
return True
elif w.startswith("-") and len(w) > 1:
for ch in w[1:]:
if ch in "qm":
return True
if ch in GREP_ARG_OPTS:
break
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(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
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 in ("head", "read"):
return True
if cmd in ("mapfile", "readarray"):
return _mapfile_exits_early(words)
if cmd == "sed":
return bool(SED_QUIT.search(rest))
if cmd in ("awk", "gawk", "mawk", "nawk"):
return _awk_exits_early(rest)
return False
def hazards(src):
"""各管道第二级起(读 pipe 的各级)中提前退出的读取器,返回 (行号, 该级文本)。"""
return [
(line, text)
for stages in pipelines(src)
for line, text in stages[1:]
if early_exit_reader(text)
]
class ShellPipeHazardTests(unittest.TestCase):
def test_detector_flags_synthetic_hazard(self):
found = hazards("set -euo pipefail\nseq 1 200000 | grep -q 1\n")
self.assertEqual([line for line, _ in found], [2], found)
def test_detector_flags_hazard_shapes(self):
for snippet in (
'if ! seq 1 200000 | grep -q 1; then echo miss; fi',
'x="$(sed -n \'s/^k=//p\' "$f" | head -1)"',
'if ! "$bin" serve -h 2>&1 | grep -Eq -- "(^|[[:space:]])-{1,2}config"; then :; fi',
'echo "${r:+($(printf \'%s\' "$r" | head -c 120))}"',
'find . -type f \\\n | grep -m1 x',
"seq 1 200000 | sed -n '/1/{p;q;}'",
'seq 1 200000 | grep -qiE "a|b" || exit 1',
):
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",
"# seq 1 200000 | grep -q 1",
"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",
"EOF",
"",
))
self.assertEqual(hazards(src), [])
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")
found = hazards(path.read_text(encoding="utf-8"))
self.assertEqual(
found, [], "\n".join(f"{rel}:{line}: | {text}" for line, text in found)
)
if __name__ == "__main__":
unittest.main()