pbl_runtime_ext/scripts/patch_rtx0005.py
2026-09-22 14:28:52 +08:00

232 lines
10 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""PBL-RTX-0005 补丁器:修复 pbl_runtime_event_ddl.py 过程体非法 RETURN(实库 ERROR 1064)。
与上一轮被 QC 退回的 .rtx5tmp/patch_rtx0005.py 的区别(响应退回意见 #7):
1) 先 dry-run 校验(每处替换的原文必须**恰好命中 1 次**,且替换后全文裸 `RETURN;` 计数为 0、
新门禁函数存在、ast 可解析),任一不满足 → 不落盘、rc=1;
2) 写盘后**重新 read 目标文件**做二次断言(不把 print 输出当成功标志);
3) 二次断言失败则回滚为原始内容并 rc=1。
用法:python3 scripts/patch_rtx0005.py [--dry-run]
"""
import argparse
import ast
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
TARGET = os.path.join(os.path.dirname(HERE), "scripts", "pbl_runtime_event_ddl.py")
# ---------------------------------------------------------------- 替换 1:过程体非法 RETURN
OLD_PROC = """ IF p_table <> '%(table)s' THEN
RETURN; -- 只守护事件表,其他表不受影响
END IF;
IF IFNULL(@pbl_guard_ctx, '') = '%(ctx)s' THEN
RETURN; -- break-glass:一次性历史数据回填
END IF;
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED',
MYSQL_ERRNO = 45001;
"""
NEW_PROC = """ -- PBL-RTX-0005 根因修复:MariaDB/MySQL 的 RETURN 只允许出现在 FUNCTION 体内。
-- 过程体里的 RETURN 语句既是服务端语法错误,其后的分号又会让 mysql CLI 在
-- DELIMITER $$ 生效之前提前切分语句 -> 实库装载 ERROR 1064 (42000),并连带后续
-- CREATE PROCEDURE pbl_runtime_event_ensure_partitions 未执行(CALL 报 1305)。
-- 等价改写为「正向条件 + 单语句 SIGNAL」:仅当守护对象是本表、且当前会话不处于
-- break-glass 上下文时才拒绝,语义与原先两次提前退出完全一致;
-- append-only 守卫未被删除、未被注释,仍然真实装载生效。
IF p_table = '%(table)s' AND IFNULL(@pbl_guard_ctx, '') <> '%(ctx)s' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED',
MYSQL_ERRNO = 45001;
END IF;
"""
# ---------------------------------------------------------------- 替换 2:新增静态门禁
OLD_CONST = ('_BARE_TOKEN = re.compile(r"^(CREATE|DROP|ALTER|GRANT|REVOKE)\\s*\\$\\$\\s*$", re.I)\n')
NEW_CONST = OLD_CONST + '''
# ---------------------------------------------------------- 过程体非法 RETURN 静态门禁
# PBL-RTX-0005:本缺陷的根因形态是「过程/触发器体内出现裸 RETURN 语句」。
# MariaDB 只允许 FUNCTION 使用 RETURN;PROCEDURE/TRIGGER/EVENT 体内出现即服务端报错,
# 且 RETURN 后的分号会让 mysql CLI 在 DELIMITER 块内提前切分语句(ERROR 1064)——
# 光靠 DELIMITER 包裹修不掉,必须让过程体本身不存在这种语句。
# 因此新增下列常量 + _procedure_body_return_errors(),并在 verify() 里作为硬门禁,
# 使同类缺陷在 --verify(离线)阶段就被拦下,而不是等实库装载才暴露。
_ILLEGAL_RETURN = re.compile(r"^RETURN\\b[^A-Za-z0-9_]*(\\$\\$)?$", re.I)
# 行尾注释(形如 `RETURN; -- 说明`):判定前必须先剥掉,否则 RETURN 会被漏检
_TRAILING_COMMENT = re.compile(r"\\s*--[^\\n]*$")
_PROC_HEAD = re.compile(r"^\\s*(CREATE\\s+(PROCEDURE|TRIGGER|FUNCTION|EVENT))\\b", re.I)
_FUNC_HEAD = re.compile(r"^\\s*CREATE\\s+FUNCTION\\b", re.I)
def _procedure_body_return_errors(text, label=""):
"""扫描渲染后的 SQL 产物,报告「过程/触发器/EVENT 体内的非法 RETURN」缺陷列表。
判定口径:
· 从 `CREATE PROCEDURE|TRIGGER|FUNCTION|EVENT` 起进入过程体,到该语句收尾 `END` 退出;
· 每行先剥掉行尾 `--` 注释再判定,`RETURN;` / `RETURN $$` / 裸 `RETURN` 全部命中;
· `CREATE FUNCTION` 体放行(RETURN 在函数中合法);
· BEGIN/END 嵌套深度用于定位语句收尾,嵌套子块(END IF/END LOOP)不算收尾。
返回 ["[label] L<n> ...", ...];空列表 = 通过。
"""
errs = []
pfx = ("[%s] " % label) if label else ""
depth = 0
in_proc = False
is_func = False
for i, raw in enumerate(text.replace("\\r\\n", "\\n").split("\\n"), 1):
s = _TRAILING_COMMENT.sub("", raw).strip()
if not s:
continue
if not in_proc and _PROC_HEAD.match(s):
in_proc = True
is_func = bool(_FUNC_HEAD.match(s))
if not in_proc:
continue
if _BLOCK_OPEN.match(s):
depth += 1
if _ILLEGAL_RETURN.match(s) and not is_func:
errs.append(pfx + "L%d 过程体出现非法 RETURN 语句(MariaDB 仅 FUNCTION 可用 RETURN,"
"且该分号会使 CLI 提前切分语句 -> ERROR 1064): %r"
% (i, raw.strip()))
if _BLOCK_CLOSE.match(s) and depth > 0:
depth -= 1
if depth == 0:
in_proc = False
is_func = False
return errs
# 门禁自检样本:故意含非法 RETURN 的过程体,verify() 用它断言「门禁真的会报错」。
# 用字符串拼接构造,避免本 .py 源文件自身出现裸 RETURN; 行(会被门禁自检与补丁断言误判)。
_ILLEGAL_RETURN_PROBE = (
"CREATE PROCEDURE `pbl_probe_illegal`()\\n"
"BEGIN\\n"
" IF 1 = 1 THEN\\n"
" " + "RETURN" + ";\\n"
" END IF;\\n"
"END;\\n"
)
'''
# ---------------------------------------------------------------- 替换 3:verify() 接入门禁
OLD_VERIFY_TAIL = ''' msgs.append("PASS 幂等关键字逐文件覆盖: 01 建表分区 / 02 过程+双触发器 / 03 双过程+EVENT")
return ok, msgs
'''
NEW_VERIFY_TAIL = ''' msgs.append("PASS 幂等关键字逐文件覆盖: 01 建表分区 / 02 过程+双触发器 / 03 双过程+EVENT")
# 10. 过程体非法 RETURN 硬门禁(PBL-RTX-0005:实库 ERROR 1064 的根因形态)
ret_errs = []
for label, text in artifacts:
ret_errs.extend(_procedure_body_return_errors(text, label))
if ret_errs:
ok = False
for e in ret_errs:
msgs.append("FAIL 过程体非法 RETURN: " + e)
else:
msgs.append("PASS 过程体无非法 RETURN(PBL-RTX-0005 门禁): 4 个产物的过程/触发器体均为"
"等价正向条件单语句 SIGNAL,append-only 守卫完整保留")
# 11. 门禁有效性自检:构造含非法 RETURN 的过程体,_procedure_body_return_errors 必须报错;
# 报不出来说明门禁本身失效(防止「加了个永远返回空的检查」这种假加固)。
probe = _procedure_body_return_errors(_ILLEGAL_RETURN_PROBE, "probe")
clean_probe = _procedure_body_return_errors(append_only_sql(), "clean")
if not any("RETURN" in e for e in probe):
ok = False
msgs.append("FAIL 非法 RETURN 门禁失效:含裸 RETURN 的过程体未被检出(假门禁)")
elif clean_probe:
ok = False
msgs.append("FAIL 非法 RETURN 门禁误报:修复后的 append_only_sql 仍被检出 %s" % clean_probe)
else:
msgs.append("PASS 非法 RETURN 门禁自检:坏样本(含 RETURN;)被检出 %d 条、"
"好样本(修复后守卫)零误报" % len(probe))
return ok, msgs
'''
REPLACEMENTS = [("过程体非法 RETURN 改写为正向条件单语句 SIGNAL", OLD_PROC, NEW_PROC),
("新增 _ILLEGAL_RETURN/_TRAILING_COMMENT/_procedure_body_return_errors",
OLD_CONST, NEW_CONST),
("verify() 接入过程体 RETURN 门禁 + 门禁有效性自检", OLD_VERIFY_TAIL, NEW_VERIFY_TAIL)]
# 源文件中「裸 RETURN;」行(门禁自检样本用拼接构造,故此处 0 命中即为干净)
_BARE_RETURN_RE = re.compile(r"^\s*RETURN\s*;\s*(--.*)?$", re.M)
def build_new(src):
out = src
for name, old, new in REPLACEMENTS:
n = out.count(old)
if n != 1:
raise RuntimeError("替换点 %r 原文命中 %d 次(应为 1 次),中止不落盘" % (name, n))
out = out.replace(old, new, 1)
return out
def check(text):
"""落盘前/后统一断言,返回失败原因列表(空 = 通过)。"""
bad = []
if "PBL-RTX-0005" not in text:
bad.append("缺少 PBL-RTX-0005 标记")
hits = _BARE_RETURN_RE.findall(text)
if hits:
bad.append("仍残留裸 RETURN; 行 %d 处" % len(hits))
for token in ["def _procedure_body_return_errors", "_TRAILING_COMMENT",
"_ILLEGAL_RETURN", "_ILLEGAL_RETURN_PROBE",
"IF p_table = '%(table)s' AND IFNULL(@pbl_guard_ctx, '') <> '%(ctx)s' THEN"]:
if token not in text:
bad.append("缺少 %s" % token)
try:
ast.parse(text)
except SyntaxError as exc:
bad.append("语法错误: %s" % exc)
return bad
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
with open(TARGET, encoding="utf-8") as f:
src = f.read()
print("[patch] 目标=%s 原始 %d 字节 / %d 行" % (TARGET, len(src), src.count("\n") + 1))
print("[patch] 原始文件裸 RETURN; 行数=%d(缺陷实况)" % len(_BARE_RETURN_RE.findall(src)))
try:
new = build_new(src)
except RuntimeError as exc:
print("[patch] DRY-RUN 失败: %s" % exc)
return 1
bad = check(new)
if bad:
print("[patch] dry-run 校验不通过,不落盘:")
for b in bad:
print(" - " + b)
return 1
print("[patch] dry-run 校验通过(%d 处替换,新文 %d 字节 / %d 行)"
% (len(REPLACEMENTS), len(new), new.count("\n") + 1))
if args.dry_run:
return 0
with open(TARGET, "w", encoding="utf-8") as f:
f.write(new)
# 写盘后二次读取校验(响应退回意见 #7:不能把 print 当成功标志)
with open(TARGET, encoding="utf-8") as f:
again = f.read()
bad = check(again)
if again != new:
bad.append("写盘后重读内容与预期不一致")
if bad:
print("[patch] 写盘后校验失败,回滚原始内容:")
for b in bad:
print(" - " + b)
with open(TARGET, "w", encoding="utf-8") as f:
f.write(src)
return 1
print("[patch] 写盘后重读校验通过:裸 RETURN; 计数=0、门禁函数/常量齐备、ast 解析 OK")
return 0
if __name__ == "__main__":
sys.exit(main())