deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-22 14:28:52 +08:00
parent 9a79e6379d
commit dfffebb089
6 changed files with 588 additions and 35 deletions

View File

@ -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; -- 只守护事件表,其他表不受影响
END IF;
IF IFNULL(@pbl_guard_ctx, '') = 'append_only_admin' THEN
RETURN; -- break-glass:一次性历史数据回填
END IF;
-- 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;
END$$
-- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方

231
scripts/patch_rtx0005.py Normal file
View File

@ -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<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())

View File

@ -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; -- 只守护事件表,其他表不受影响
END IF;
IF IFNULL(@pbl_guard_ctx, '') = '%(ctx)s' THEN
RETURN; -- break-glass:一次性历史数据回填
END IF;
-- 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;
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<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"
)
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 "?"

View File

@ -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; -- 只守护事件表,其他表不受影响
END IF;
IF IFNULL(@pbl_guard_ctx, '') = 'append_only_admin' THEN
RETURN; -- break-glass:一次性历史数据回填
END IF;
-- 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;
END$$
-- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方

View File

@ -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())

View File

@ -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)