From dfffebb089a741d6a5ce152bbcbd82e5838c6268 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Tue, 22 Sep 2026 14:28:52 +0800 Subject: [PATCH] =?UTF-8?q?deliver:=20=E4=BA=A4=E4=BB=98=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E5=BC=95=E6=93=8E=E4=BB=A3=E4=B8=BA=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._m11b_02_pbl_runtime_event_append_only.sql | 19 +- scripts/patch_rtx0005.py | 231 ++++++++++++++++++ scripts/pbl_runtime_event_ddl.py | 139 +++++++++-- sql/m11b_partitions.sql | 19 +- tests/evidence/rtx0005_live_evidence.py | 186 ++++++++++++++ tests/test_m11b1a_ddl_runtime.py | 29 ++- 6 files changed, 588 insertions(+), 35 deletions(-) create mode 100644 scripts/patch_rtx0005.py create mode 100644 tests/evidence/rtx0005_live_evidence.py diff --git a/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql b/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql index 0bd5e91..f497809 100644 --- a/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql +++ b/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql @@ -11,15 +11,18 @@ CREATE PROCEDURE `pbl_assert_append_only`( IN p_table VARCHAR(64), IN p_priv VARCHAR(16), IN p_detail VARCHAR(255)) READS SQL DATA BEGIN - IF p_table <> 'pbl_runtime_event' THEN - RETURN; -- 只守护事件表,其他表不受影响 + -- 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 = 'pbl_runtime_event' AND IFNULL(@pbl_guard_ctx, '') <> 'append_only_admin' THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED', + MYSQL_ERRNO = 45001; END IF; - IF IFNULL(@pbl_guard_ctx, '') = 'append_only_admin' THEN - RETURN; -- break-glass:一次性历史数据回填 - END IF; - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED', - MYSQL_ERRNO = 45001; END$$ -- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方 diff --git a/scripts/patch_rtx0005.py b/scripts/patch_rtx0005.py new file mode 100644 index 0000000..50bdaa4 --- /dev/null +++ b/scripts/patch_rtx0005.py @@ -0,0 +1,231 @@ +#!/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 ...", ...];空列表 = 通过。 + """ + 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()) diff --git a/scripts/pbl_runtime_event_ddl.py b/scripts/pbl_runtime_event_ddl.py index b4d3714..7ec3f0c 100644 --- a/scripts/pbl_runtime_event_ddl.py +++ b/scripts/pbl_runtime_event_ddl.py @@ -115,6 +115,9 @@ TX_EVENT_COLUMNS = ["tenant_id", "event_code", "idem_key", "world_id", "session_ LEGACY_COLUMNS = ["occurred_at"] GUARD_ADMIN_CTX = "append_only_admin" +# 连库验证探针行的租户标识(PBL-RTX-0005:由散落在 SQL 字面量里的硬编码提为常量, +# 便于 verify_db() 与外部 harness 各自按自己的探针租户反查,不再写死。) +PROBE_TENANT = "t_probe" def column_names(): @@ -175,15 +178,18 @@ CREATE PROCEDURE `pbl_assert_append_only`( IN p_table VARCHAR(64), IN p_priv VARCHAR(16), IN p_detail VARCHAR(255)) READS SQL DATA BEGIN - IF p_table <> '%(table)s' THEN - RETURN; -- 只守护事件表,其他表不受影响 + -- 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; - 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; END; -- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方 @@ -396,6 +402,70 @@ _INLINE_DDL_HEAD = re.compile( _BARE_TOKEN = re.compile(r"^(CREATE|DROP|ALTER|GRANT|REVOKE)\s*\$\$\s*$", re.I) +# ---------------------------------------------------------- 过程体非法 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 ...", ...];空列表 = 通过。 + """ + 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" +) + + def _with_delimiter(body, delim="$$"): """把过程/触发器/EVENT 正文按语句切分,逐条以 $$ 收尾,交给 mysql CLI(DELIMITER)执行。 @@ -680,6 +750,32 @@ def verify(): msgs.append("FAIL 幂等/结构关键字缺失: " + ", ".join(miss_all)) else: 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 @@ -859,19 +955,26 @@ def _explain_partitions(cur, sql, params=None): return found -def _row_in_partition(cur, part, uid): - """SELECT ... FROM t PARTITION(p) 反查:探针行是否物理落在该分区。""" +def _row_in_partition(cur, part, uid, tenant=PROBE_TENANT): + """SELECT ... FROM t PARTITION(p) 反查:探针行是否物理落在该分区。 + + PBL-RTX-0005:探针租户由硬编码 't_probe' 改为形参(默认值仍为 t_probe, + verify_db() 行为零变化)。此前实库 harness 用 't_harness' 插探针行,物理反查 + 永远命中不到(WHERE tenant_id='t_probe' 恒空),只能退回 EXPLAIN 分支—— + 而 EXPLAIN 分支同样带死的 tenant 条件,最终返回 '?'。该缺陷在 DDL 能装载前 + 根本没机会执行(B 段整体 ERROR 1064),修好装载后才暴露,故一并处置。 + """ sql = _render("SELECT id FROM `%s` PARTITION (`%s`)" - " WHERE tenant_id='t_probe' AND event_uid=%%s" % (TABLE, _norm_part(part)), - placeholders=1) + " WHERE tenant_id=%%s AND event_uid=%%s" % (TABLE, _norm_part(part)), + placeholders=2) try: - cur.execute(sql, (uid,)) + cur.execute(sql, (tenant, uid)) except Exception: # noqa: BLE001 return False return bool(cur.fetchone()) -def _locate_row_partition(cur, uid, ts, part_names): +def _locate_row_partition(cur, uid, ts, part_names, tenant=PROBE_TENANT): """定位某探针行实际所在分区(QC #6)。 1) 首选按 information_schema 分区清单逐个 PARTITION(p) 反查物理位置——与 EXPLAIN @@ -882,13 +985,13 @@ def _locate_row_partition(cur, uid, ts, part_names): # 1) 逐个 PARTITION(p) 物理反查(复用 _row_in_partition,SQL 只在其内拼装一次, # 避免同一份转义逻辑在两处各写一遍、改一处漏一处) for part in part_names: - if _row_in_partition(cur, part, uid): + if _row_in_partition(cur, part, uid, tenant): return _norm_part(part) # 2) 反查不中才退回 EXPLAIN FORMAT=JSON;标识符渲染与转义自检统一走 _render - probe_sql = _render("SELECT id FROM `%s` WHERE tenant_id='t_probe'" + probe_sql = _render("SELECT id FROM `%s` WHERE tenant_id=%%s" " AND event_uid=%%s AND created_at=%%s" % TABLE, - placeholders=2) - planned = _explain_partitions(cur, probe_sql, (uid, ts)) + placeholders=3) + planned = _explain_partitions(cur, probe_sql, (tenant, uid, ts)) if len(planned) == 1: return planned[0] return ",".join(planned) or "?" diff --git a/sql/m11b_partitions.sql b/sql/m11b_partitions.sql index 1c7cd0b..7398d3c 100644 --- a/sql/m11b_partitions.sql +++ b/sql/m11b_partitions.sql @@ -66,15 +66,18 @@ CREATE PROCEDURE `pbl_assert_append_only`( IN p_table VARCHAR(64), IN p_priv VARCHAR(16), IN p_detail VARCHAR(255)) READS SQL DATA BEGIN - IF p_table <> 'pbl_runtime_event' THEN - RETURN; -- 只守护事件表,其他表不受影响 + -- 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 = 'pbl_runtime_event' AND IFNULL(@pbl_guard_ctx, '') <> 'append_only_admin' THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED', + MYSQL_ERRNO = 45001; END IF; - IF IFNULL(@pbl_guard_ctx, '') = 'append_only_admin' THEN - RETURN; -- break-glass:一次性历史数据回填 - END IF; - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED', - MYSQL_ERRNO = 45001; END$$ -- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方 diff --git a/tests/evidence/rtx0005_live_evidence.py b/tests/evidence/rtx0005_live_evidence.py new file mode 100644 index 0000000..bef35a6 --- /dev/null +++ b/tests/evidence/rtx0005_live_evidence.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""PBL-RTX-0005 实库取证:修复后 DDL 在一次性 MariaDB 沙箱中的装载与守卫行为。 + +响应 QC 退回意见 #4:上一轮留存的是**修复前**的失败日志(ERROR 1064 at line 65), +本轮重新拉起沙箱库,用部署同款路径(mariadb CLI + DELIMITER)装载修复后的 +full_script(),留存: + 1) mariadb-install-db / mariadbd 启动与建库建号输出; + 2) DDL 装载 rc 与 stderr(应为空); + 3) SHOW PROCEDURE STATUS / SHOW TRIGGERS(对象真实存在); + 4) 一次 append-only 拒绝(CALL pbl_assert_append_only -> SQLSTATE 45000 / 45001); + 5) 一次 break-glass 放行(SET @pbl_guard_ctx='append_only_admin' 后 CALL 不报错); + 6) UPDATE / DELETE 触发器拒绝 + INSERT 正常 + CALL ensure_partitions(9) 成功; + 7) 结束后彻底销毁 datadir。 + +凭据只在进程内使用:从 projects/pbls/env/test.json 读 db 段,不 echo、不落盘、不 commit。 +用法:python3 tests/evidence/rtx0005_live_evidence.py +""" +import json +import os +import shutil +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +MODULE_DIR = os.path.dirname(os.path.dirname(HERE)) +WORKSPACE = os.path.dirname(os.path.dirname(MODULE_DIR)) +GEN = os.path.join(MODULE_DIR, "scripts", "pbl_runtime_event_ddl.py") +SANDBOX = os.environ.get("RTX0005_SANDBOX") or "/tmp/rtx0005_evidence_db" +PORT = int(os.environ.get("RTX0005_DB_PORT") or 13317) + + +def sh(cmd, **kw): + r = subprocess.run(cmd, capture_output=True, text=True, **kw) + return r + + +def cli(dbname, sql, user="root", pw=None): + cmd = ["mariadb", "--protocol=tcp", "-h", "127.0.0.1", "-P", str(PORT), "-u", user] + if pw: + cmd.append("-p" + pw) + cmd += [dbname] + return sh(cmd, input=sql) + + +def main(): + print("=" * 78) + print("PBL-RTX-0005 实库取证(修复后 DDL 装载 + 守卫行为)") + print("时间: %s" % time.strftime("%Y-%m-%d %H:%M:%S %z")) + print("=" * 78) + conf = json.load(open(os.path.join(WORKSPACE, "projects", "pbls", "env", "test.json"), + encoding="utf-8"))["db"] + dbname, user, pw = conf["dbname"], conf["user"], conf["password"] + print("[1] 连接参数来源: projects/pbls/env/test.json db 段(dbname=%s user=%s,凭据不打印)" + % (dbname, user)) + + shutil.rmtree(SANDBOX, ignore_errors=True) + os.makedirs(SANDBOX) + r = sh(["mariadb-install-db", "--user=%s" % os.environ.get("USER", "pipeline"), + "--datadir=%s" % SANDBOX, "--auth-root-authentication-method=normal"]) + print("[2] mariadb-install-db rc=%d" % r.returncode) + if r.returncode: + print(r.stdout[-800:] + r.stderr[-800:]) + return 1 + srv = subprocess.Popen(["mariadbd", "--user=%s" % os.environ.get("USER", "pipeline"), + "--datadir=%s" % SANDBOX, "--socket=%s/my.sock" % SANDBOX, + "--port=%d" % PORT, "--bind-address=127.0.0.1", + "--pid-file=%s/my.pid" % SANDBOX, "--skip-name-resolve", + "--log-error=%s/server.err" % SANDBOX], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + ready = False + for _ in range(40): + if cli("mysql", "SELECT 1").returncode == 0: + ready = True + break + time.sleep(1) + if not ready: + print("[3] FAIL: mariadbd 未就绪") + srv.terminate() + shutil.rmtree(SANDBOX, ignore_errors=True) + return 1 + print("[3] mariadbd 就绪: %s" % cli("mysql", "SELECT VERSION()").stdout.strip().split("\n")[1]) + + print("[4] 建库建号 rc=%d" % cli("mysql", "CREATE DATABASE IF NOT EXISTS `%s` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" + "CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s';" + "GRANT ALL PRIVILEGES ON *.* TO '%s'@'%%' WITH GRANT OPTION;" + "FLUSH PRIVILEGES;" + % (dbname, user, pw.replace("'", "''"), user)).returncode) + + # 渲染修复后的完整 DDL(与部署路径一致:mariadb CLI + DELIMITER) + sys.path.insert(0, MODULE_DIR) + import importlib.util + spec = importlib.util.spec_from_file_location("gen_rtx0005", GEN) + gen = importlib.util.module_from_spec(spec) + spec.loader.exec_module(gen) + full = os.path.join(SANDBOX, "full_after_fix.sql") + with open(full, "w", encoding="utf-8") as f: + f.write(gen.full_script(dbname=dbname, app_user=user)) + print("[5] full_script() 渲染 %d bytes(修复后,无过程体 RETURN)" % os.path.getsize(full)) + + load = cli(dbname, open(full, encoding="utf-8").read(), user=user, pw=pw) + print("[6] DDL 装载 rc=%d" % load.returncode) + print(" stdout: %s" % (load.stdout.strip().replace("\n", " | ") or "(空)")) + print(" stderr: %s" % (load.stderr.strip().replace("\n", " | ") or "(空) <-- 修复前此处为 " + "ERROR 1064 (42000) at line 65")) + rc_all = 0 if load.returncode == 0 else 1 + + print("\n[7] SHOW PROCEDURE STATUS(过程真实存在)") + print(cli(dbname, "SELECT DB, NAME, TYPE FROM information_schema.ROUTINES " + "WHERE ROUTINE_SCHEMA='%s' ORDER BY NAME;" % dbname, user=user, pw=pw).stdout) + print("[8] SHOW TRIGGERS(触发器真实存在)") + print(cli(dbname, "SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING, EVENT_OBJECT_TABLE" + " FROM information_schema.TRIGGERS WHERE EVENT_OBJECT_SCHEMA='%s' " + "ORDER BY TRIGGER_NAME;" % dbname, user=user, pw=pw).stdout) + print("[9] 分区清单") + print(cli(dbname, "SELECT PARTITION_NAME, PARTITION_DESCRIPTION FROM information_schema.PARTITIONS" + " WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='pbl_runtime_event' " + "ORDER BY PARTITION_ORDINAL_POSITION;" % dbname, user=user, pw=pw).stdout) + + print("[10] CALL pbl_runtime_event_ensure_partitions(9)(修复前报 1305 does not exist)") + r10 = cli(dbname, "CALL pbl_runtime_event_ensure_partitions(9);", user=user, pw=pw) + print(" rc=%d stderr=%s" % (r10.returncode, r10.stderr.strip() or "(空)")) + print(" 调用后分区数=%s" % cli(dbname, "SELECT COUNT(*) FROM information_schema.PARTITIONS" + " WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='pbl_runtime_event';" + % dbname, user=user, pw=pw).stdout.split("\n")[1]) + rc_all |= r10.returncode + + print("[11] append-only 守卫:非 break-glass 会话 CALL pbl_assert_append_only 必须被拒(45001)") + r11 = cli(dbname, "CALL pbl_assert_append_only('pbl_runtime_event','update','probe-deny');", + user=user, pw=pw) + print(" rc=%d stderr=%s" % (r11.returncode, r11.stderr.strip())) + if r11.returncode == 0 or "PBL_APPEND_ONLY_DENIED" not in r11.stderr: + print(" FAIL: 守卫未生效") + rc_all = 1 + else: + print(" PASS: 拒绝生效(SQLSTATE 45000 / MYSQL_ERRNO 45001)") + + print("[12] break-glass 放行:SET @pbl_guard_ctx='append_only_admin' 后同一 CALL 不报错") + r12 = cli(dbname, "SET @pbl_guard_ctx='append_only_admin';" + " CALL pbl_assert_append_only('pbl_runtime_event','update','probe-allow');", + user=user, pw=pw) + print(" rc=%d stderr=%s" % (r12.returncode, r12.stderr.strip() or "(空)")) + rc_all |= r12.returncode + + print("[13] 非守护表不受影响:CALL pbl_assert_append_only('other_table',...) 应放行") + r13 = cli(dbname, "CALL pbl_assert_append_only('other_table','update','probe-other');", + user=user, pw=pw) + print(" rc=%d stderr=%s" % (r13.returncode, r13.stderr.strip() or "(空)")) + rc_all |= r13.returncode + + print("[14] 触发器层:INSERT 允许 / UPDATE 拒绝 / DELETE 拒绝(同会话内逐条验证)") + sql14 = ( + "INSERT INTO pbl_runtime_event (tenant_id,event_uid,event_type,seq,created_at)" + " VALUES ('t_rtx5','rtx5_probe_a','probe',1,'2026-09-22 00:00:00');\n" + "SELECT CONCAT('inserted=', ROW_COUNT()) AS r;\n" + "UPDATE pbl_runtime_event SET event_type='x' WHERE tenant_id='t_rtx5';\n") + r14 = cli(dbname, sql14, user=user, pw=pw) + print(" UPDATE 段 rc=%d stderr=%s" % (r14.returncode, r14.stderr.strip() or "(空)")) + r14b = cli(dbname, "DELETE FROM pbl_runtime_event WHERE tenant_id='t_rtx5';", user=user, pw=pw) + print(" DELETE 段 rc=%d stderr=%s" % (r14b.returncode, r14b.stderr.strip() or "(空)")) + if r14.returncode == 0 or r14b.returncode == 0: + print(" FAIL: 触发器未拦住 UPDATE/DELETE") + rc_all = 1 + else: + print(" PASS: INSERT 成功、UPDATE/DELETE 均被触发器拒绝") + r14c = cli(dbname, "SET @pbl_guard_ctx='append_only_admin';" + "DELETE FROM pbl_runtime_event WHERE tenant_id='t_rtx5';" + "SET @pbl_guard_ctx=NULL;", user=user, pw=pw) + print(" break-glass 清理探针 rc=%d stderr=%s" % (r14c.returncode, r14c.stderr.strip() or "(空)")) + rc_all |= r14c.returncode + + print("\n[15] 取证结论: %s" % ("PASS" if rc_all == 0 else "FAIL")) + srv.terminate() + for _ in range(20): + if not os.path.exists(os.path.join(SANDBOX, "my.pid")): + break + time.sleep(0.5) + shutil.rmtree(SANDBOX, ignore_errors=True) + print("[16] 沙箱 datadir 已销毁: %s 存在=%s" % (SANDBOX, os.path.exists(SANDBOX))) + return rc_all + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_m11b1a_ddl_runtime.py b/tests/test_m11b1a_ddl_runtime.py index 6c68f62..e379c11 100644 --- a/tests/test_m11b1a_ddl_runtime.py +++ b/tests/test_m11b1a_ddl_runtime.py @@ -202,6 +202,32 @@ def layer_b(gen): chk("B3 CALL ensure_partitions(9) 补建出新月分区", len(new) >= 3 and after.count("pmax") == 1, "新增 %s(分区数 %d→%d)" % (",".join(new), len(before), len(after))) + # ---- B4 前置:造出一个「已过保留期」的历史月分区,prune 才有真实可 DROP 的对象 ---- + # PBL-RTX-0005 说明:本任务只修 DDL 装载 ERROR 1064,**不改** prune_partitions 的保留期语义 + # (仍以 CURDATE 为锚:只 DROP 早于「当月 - p_keep_months」的整月分区,绝不碰未来预建分区与 + # pmax,避免误删数据)。一次性沙箱库里的分区全是当月/未来月,故先用 REORGANIZE 从首个 + # 月分区里拆出一个 2020-01 的历史月分区(空分区、零数据损失),再断言它被 DROP。 + cur.execute("SELECT PARTITION_NAME, PARTITION_DESCRIPTION FROM information_schema.PARTITIONS" + " WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND PARTITION_NAME<>%s" + " ORDER BY PARTITION_ORDINAL_POSITION LIMIT 1", (gen.TABLE, "pmax")) + first_name, first_upper = cur.fetchone() + rc_setup = 0 + try: + cur.execute("ALTER TABLE `%s` REORGANIZE PARTITION `%s` INTO (" + "PARTITION `p202001` VALUES LESS THAN (DATE '2020-02-01'), " + "PARTITION `%s` VALUES LESS THAN (DATE '%s'))" + % (gen.TABLE, first_name, first_name, str(first_upper).strip("'"))) + except Exception as exc: # noqa: BLE001 + rc_setup = 1 + print(" [B4-setup] REORGANIZE 失败: %s" % str(exc)[:160]) + chk("B4-setup REORGANIZE 拆出过期历史月分区 p202001(保留期语义不变)", + rc_setup == 0 and "p202001" in parts(), + "分区清单含 p202001=%s" % ("p202001" in parts())) + + # REORGANIZE 会改写分区清单,prune 的比对基线必须在 setup 之后重新采集, + # 否则 dropped 恒为空(PBL-RTX-0005 修好装载后才执行到本段,属 harness 场景缺陷, + # 未改动任何断言阈值:仍要求 dropped >= 1 且保留 pmax)。 + after = parts() cur.execute("CALL pbl_runtime_event_prune_partitions(2)") pruned = parts() dropped = [p for p in after if p not in pruned] @@ -217,7 +243,8 @@ def layer_b(gen): cur.execute("INSERT INTO `%s` (tenant_id,event_uid,event_type,scene_id,seq,created_at)" " VALUES ('t_harness',%%s,'probe',0,%%s,%%s)" % gen.TABLE, ("h_a", 1, "2026-01-05 00:00:00")) - located = gen._locate_row_partition(cur, "h_a", "2026-01-05 00:00:00", pruned) + located = gen._locate_row_partition(cur, "h_a", "2026-01-05 00:00:00", pruned, + tenant="t_harness") chk("B6 _locate_row_partition 定位到 information_schema 清单内的分区", located in pruned, "h_a -> %s(清单 %s)" % (located, ",".join(pruned))) cur.execute("SET @pbl_guard_ctx='%s'" % gen.GUARD_ADMIN_CTX)