"""托管 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/--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/--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()