diff --git a/CHANGELOG.md b/CHANGELOG.md index c795f7a..48d44c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.3.2(2026-08-26) -**本版不改库代码。** `tools/` 与 `tests/` 都不在 pip 包内(脚本随仓库分发,见 README),故 1.3.2 的 wheel 与 1.3.1 在库代码上**逐字节相同**。升级它不会改变任何库行为——本版的内容是运维脚本 `tools/telemetry_retention.py` 的一处契约扩展,以及测试隔离的重建。若你只用库本体,可以跳过本版。 +**本版不改库代码。** `tools/` 与 `tests/` 都不在 pip 包内(脚本随仓库分发,见 README),故 1.3.2 的 wheel 与 1.3.1 **除版本号外没有任何差异**(`__version__` 与包元数据是唯一的改动)。升级它不会改变任何库行为——本版的内容是运维脚本 `tools/telemetry_retention.py` 的一处契约扩展,以及测试隔离的重建。若你只用库本体,可以跳过本版。 ### 运维脚本:`--table` 让删除目标不再由连接环境决定(issue #18) diff --git a/research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md b/research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md index 1ac6c47..610c23a 100644 --- a/research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md +++ b/research-wiki/designs/2026-08-26-issue18-pg-test-isolation-design.md @@ -60,7 +60,7 @@ L3 不是测试的问题,是脚本契约的问题——它同时是生产风 | 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款;SQLite 库文件即目标,无 schema 概念,无歧义可消 | | 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**,提示写成 `schema.表名` | 单段等于没声明,隐式性原样保留 | | **表名段必须逐字等于 `llm_calls`** | `--table audit.events` → 退出 **1**,消息点明本脚本只清理 `llm_calls` | 见 §4.4:不加这条,`--table` 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具" | -| 两段均非空、均不含 `"` | 不合法 → 退出 **1** | 复杂标识符(含引号的表名)不支持,此时退回不给 `--table` 的路径;写进 `--help`。**本行原写作"均不含 `.` 与 `\"`",实现阶段核出"段内含 `.`"是不可达分支**——按 `.` 切分后恰好两段是前置条件,`a.b.c` 走的是"不是恰好两段"那条消息,故删去该半句 | +| 两段均非空;**schema 段须为普通标识符**(`[A-Za-z_][A-Za-z0-9_$]*`) | 不合法 → 退出 **1** | 复杂标识符(含引号的表名)不支持,此时退回不给 `--table` 的路径;写进 `--help`。**本行原写作"均不含 `.` 与 `\"`",实现阶段核出"段内含 `.`"是不可达分支**——按 `.` 切分后恰好两段是前置条件,`a.b.c` 走的是"不是恰好两段"那条消息,故删去该半句 | | **逐字比较,不做大小写折叠** | 传 `_quote()` 包裹的限定名给 `to_regclass` | catalog 里存的是真实标识符;未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 | | 解析不到 | 退出 **2**,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**:诊断方向不同。**退出码维持 2 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 | | 无权限 | 后续 `COUNT` 抛 `PostgresError` → 既有 except → 退出 **2** | 无需新增分支 | @@ -193,6 +193,8 @@ tests/ 下不得出现字面量 public.llm_calls —— 命中即 exit 1 | fixture / teardown 里用 admin 连接手滑写真表 | admin 连接必须存在(建 schema/角色本身就需要它),权限边界对它无效 | 工厂不把 admin DSN 交给用例;§7.2 的门能拦住字面量形态 | | 进程被 `SIGKILL` 时 pytest finalizer 不执行,残留 schema/角色 | 任何进程内机制都做不到 | 命名固定前缀 `pgw_s_` / `pgw_r_`,残留可一条 SQL 查出(`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'`);**不做自动 TTL 清理**——并行会话下"清理别人的残留"会误删正在跑的 schema,比残留本身更危险 | | 共享实例上其他项目往真表写/删 | 不归本库管 | 改完之后本仓库测试对它完全不敏感,这正是本设计的目的 | +| `production_template` 仍以管理身份执行不带限定名的 `DELETE` / `DROP TABLE` | 它有意不收敛进工厂(§7.1 末段),三角色与分区语义是它自己的 | 独立验证实测:它的连接 `search_path` **只有**自己那个 schema(`public` 不在路径里),故 `to_regclass('llm_calls')` 返回 `None`——search_path 一旦失手,报的是"关系不存在"而不是静默打到共享表 | +| `pg_catalog_probe` 持管理连接 | 工厂自测需要查 catalog 核对残留,这个能力删不掉 | 探针只接受 `SELECT` 开头的语句(有用例钉住);它不交出 DSN,故越界能力止于只读查询 | ## 10. 版本号与发布 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3388b62..edf4d0c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -73,8 +73,22 @@ def _with_search_path(dsn: str, schema: str) -> str: def _as_role(dsn: str, role: str) -> str: - """把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。""" - return re.sub(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1) + """把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。 + + **换不掉就报错,绝不原样返回**: `postgresql://h:5432/db`(口令走 PGPASSWORD / + .pgpass / trust)与 `postgresql:///db?host=/var/run/postgresql`(unix socket) + 都是合法 DSN,却没有可替换的内联凭据段。静默返回原串的后果不是测试报错,而是 + 沙箱以**管理身份**建成、用例照常绿,同时 `bare_dsn` 变成超级用户连接——最坏 + 情况用例会拿它跑真实 `--apply`,删空共享表之后才在退出码断言上红。 + 这正是 P5"严禁默认值掩盖错误"要挡的形态。 + """ + swapped, count = re.subn(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1) + if count != 1: + raise RuntimeError( + f"DSN 里没有可替换的内联凭据段,沙箱角色 {role} 无法生效,拒绝以管理身份继续。" + "请把 PGW_TELEMETRY_PG_DSN 写成 postgresql://<用户>:<口令>@<主机>/<库> 的形态。" + ) + return swapped @pytest.fixture @@ -89,6 +103,10 @@ async def pg_catalog_probe(): dsn = _require_admin_dsn() async def probe(sql: str, *args: object) -> list[tuple]: + # 只读校验不是形式主义: 这个闭包持的是管理连接,不设限就等于把"用例够不到 + # 管理能力"这句话降格成一句 docstring 里的请求。 + if not sql.lstrip().upper().startswith("SELECT"): + raise RuntimeError(f"pg_catalog_probe 只接受 SELECT 语句,收到: {sql[:60]!r}") conn = await asyncpg.connect(dsn, timeout=10) try: return [tuple(r) for r in await conn.fetch(sql, *args)] @@ -194,18 +212,37 @@ async def pg_sandbox(): f"GRANT {', '.join(grants)} ON ALL TABLES IN SCHEMA {schema} TO {role_name}" ) # 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项 + + used = bare if role_name is not None else admin_dsn + sandbox = PgSandbox( + schema=schema, + role=role_name, + dsn=_with_search_path(used, schema), + bare_dsn=bare, + ) + if role_name is not None: + # 字符串替换成功不等于连上去就是那个角色(PGUSER 等环境变量仍可能 + # 盖掉 DSN 里的用户名)。这道校验按**实际身份**兜底: 整个设计的价值 + # 都压在"跑脚本的那个连接对共享表无权"上,不值得只用一次字符串比较 + # 来担保。它必须留在 try 之内——出了这个块,清理动作已经并进 fixture + # 级的栈,再回滚一次就会对同一个角色跑两遍 DROP OWNED BY(它没有 + # IF EXISTS,第二遍必报错)。 + conn = await asyncpg.connect(sandbox.dsn, timeout=10) + try: + actual = await conn.fetchval("SELECT current_user") + finally: + await conn.close() + if actual != role_name: + raise RuntimeError( + f"沙箱 DSN 连上去的身份是 {actual!r},不是预期的 {role_name!r};" + "权限边界不成立,拒绝把这个沙箱交出去。" + ) except BaseException: await _unwind(local) raise cleanups.extend(local) - used = bare if role_name is not None else admin_dsn - return PgSandbox( - schema=schema, - role=role_name, - dsn=_with_search_path(used, schema), - bare_dsn=bare, - ) + return sandbox yield make await _unwind(cleanups) diff --git a/tests/integration/test_pg_sandbox.py b/tests/integration/test_pg_sandbox.py index 4fd6447..7c534f7 100644 --- a/tests/integration/test_pg_sandbox.py +++ b/tests/integration/test_pg_sandbox.py @@ -10,6 +10,8 @@ from __future__ import annotations import pytest +from tests.integration.conftest import _as_role + _DDL = "CREATE TABLE llm_calls (call_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())" @@ -40,6 +42,42 @@ async def _oid_of_llm_calls(dsn: str) -> int | None: await conn.close() +class TestRoleDsnConstruction: + """凭据替换失败必须**当场报错**,不许退回管理身份(合并前审查的 P1)。 + + 这条防线的失效形态特别隐蔽: 替换不上时 `re.sub` 原样返回管理连接串,沙箱 + "看起来"建好了、用例照常绿,而 `bare_dsn` 其实是超级用户——最坏情况用例 + 会拿它跑真实 `--apply`,把共享表删空之后才在 `assert returncode == 2` 上红。 + 行已经没了。设计 §5.1 要的是"越界做不到",不是"越界会被发现"。 + """ + + def test_inline_credentials_are_replaced(self): + swapped = _as_role("postgresql://app:secret@h:5432/polygateway", "pgw_r_x") + + assert swapped.startswith("postgresql://pgw_r_x:") + assert "app:secret" not in swapped + + @pytest.mark.parametrize( + "dsn", + [ + "postgresql://h:5432/polygateway", # 口令走 PGPASSWORD / .pgpass / trust + "postgresql:///polygateway?host=/var/run/postgresql", # unix socket + ], + ) + def test_a_dsn_without_inline_credentials_is_refused(self, dsn): + """这两种都是合法 DSN,今天的 .env 恰好不是它们——恰好而已。""" + with pytest.raises(RuntimeError, match="沙箱角色"): + _as_role(dsn, "pgw_r_x") + + +class TestCatalogProbeIsReadOnly: + """探针拿的是管理连接,故它只许查——否则"用例够不到管理能力"就是句空话。""" + + async def test_non_select_statements_are_refused(self, pg_catalog_probe): + with pytest.raises(RuntimeError, match="只接受 SELECT"): + await pg_catalog_probe("DELETE FROM llm_calls WHERE call_id = 'nope'") + + class TestSchemaOnlySandbox: async def test_table_lands_in_the_sandbox_schema_and_bare_dsn_is_absent(self, pg_sandbox): """`role="none"`: 表落在自建 schema 下;不发角色,故没有裸 DSN 可给。""" diff --git a/tests/unit/test_retention_tool.py b/tests/unit/test_retention_tool.py index 94ef3e3..2bbd1c1 100644 --- a/tests/unit/test_retention_tool.py +++ b/tests/unit/test_retention_tool.py @@ -360,6 +360,59 @@ class TestUsageErrors: assert "llm_calls" in result.stderr +class TestTableIdentifierWhitelist: + """schema 段只收普通标识符: 让 `--help` 说的"不支持复杂标识符"成为事实。 + + 这不是安全边界(`to_regclass($1)` 参数化 + `_quote` 转义,注入面本就不存在), + 是**契约边界**: 帮助文本写着不支持,实现却照单全收,受害的是照文档做判断的人。 + """ + + def test_schema_with_a_space_exits_one(self): + result = _run( + "--backend", + "postgres", + "--dsn", + "postgresql://x/y", + "--older-than-days", + "7", + "--table", + "bad schema.llm_calls", + ) + + assert result.returncode == 1 + assert "--table" in result.stderr + + def test_schema_with_a_semicolon_exits_one(self): + result = _run( + "--backend", + "postgres", + "--dsn", + "postgresql://x/y", + "--older-than-days", + "7", + "--table", + "a;b.llm_calls", + ) + + assert result.returncode == 1 + assert "--table" in result.stderr + + def test_a_plain_identifier_with_underscores_and_digits_is_accepted(self): + """收紧不得误伤正常名字: 这条走到连接阶段才失败(退出 2),说明校验放行了。""" + result = _run( + "--backend", + "postgres", + "--dsn", + "postgresql://127.0.0.1:1/nope", + "--older-than-days", + "7", + "--table", + "pgw_s_a1b2c3.llm_calls", + ) + + assert result.returncode == 2 + + class TestHelp: def test_help_names_the_maintenance_role_and_the_recommended_path(self): """帮助文本是运维唯一会读的文档,权限口径与"推荐不是 DELETE"必须在里面。""" diff --git a/tools/telemetry_retention.py b/tools/telemetry_retention.py index ff50000..ca12c37 100644 --- a/tools/telemetry_retention.py +++ b/tools/telemetry_retention.py @@ -19,6 +19,7 @@ from __future__ import annotations import argparse import asyncio +import re import sqlite3 import sys from datetime import UTC, datetime, timedelta @@ -30,6 +31,11 @@ if TYPE_CHECKING: TABLE = "llm_calls" +# --table 的 schema 段白名单。收紧到普通标识符不是为了防注入(目标名走 to_regclass +# 的参数化占位,且用 _quote 转义),而是让 --help 里"不支持复杂标识符"这句话与实现 +# 一致——文档说不支持、实现却照单全收,受害的是照文档做判断的人。 +_PLAIN_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*") + # 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则 EXIT_OK = 0 EXIT_USAGE = 1 @@ -195,12 +201,13 @@ def _parse_table(parser: _Parser, value: str) -> str: if len(segments) != 2: parser.error(f"--table 必须是 .{TABLE} 这样的两段限定名,当前: {value!r}") schema, name = segments - # 段内不可能再含 "." (上面按 "." 切成恰好两段),故此处只需查引号 + # 段内不可能再含 "." (上面按 "." 切成恰好两段),故此处只查其余形态 if not schema or not name: parser.error(f"--table 的 schema 段与表名段都不得为空,当前: {value!r}") - if '"' in schema or '"' in name: + if not _PLAIN_IDENTIFIER.fullmatch(schema): parser.error( - f"--table 不支持含引号的复杂标识符,当前: {value!r};" + f"--table 的 schema 段只接受普通标识符(字母或下划线开头,其后字母/数字/" + f"下划线/$),当前: {value!r};含空格、引号等需要加引号的复杂标识符不支持," "这种情形请不给 --table,退回 search_path 解析那条路径。" ) if name != TABLE: