fix: refuse the sandbox rather than quietly running it as the superuser
Both reviews landed on the same line independently. _as_role swaps the credentials in the DSN with a regex, and when the pattern does not match it returned the string unchanged. Two shapes miss it: no inline credentials, and a unix socket URL. Either one is a legal DSN. What that costs is not a broken test. The sandbox builds, every assertion still passes, and bare_dsn is now the admin connection, so the worst-case case runs the real script with --apply as a superuser against the shared table. The verifier ran that command as a dry run to see what it would have done: target public.llm_calls, 11 rows to delete. The case would still have gone red on the exit code, after the rows were gone. It raises now. There is also a second check that connects and compares current_user, because a successful string substitution is not the same as connecting as that role -- PGUSER and friends still override. The whole design rests on that connection having no grant on the shared table; a string comparison is too thin a thing to rest it on. That check has to stay inside the try. Past it the cleanup statements have already been merged into the fixture-level stack, and unwinding again runs DROP OWNED BY twice, which has no IF EXISTS. The catalog probe took any SQL and ran it on the admin connection. The design claims withholding the DSN makes the boundary structural; that was only true of the connection string, not of the capability. It takes SELECT now. --table's schema half is restricted to plain identifiers. Not a security fix, since the name goes through a parameter and _quote: the help text says complex identifiers are unsupported and the code was accepting them anyway.
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 1.3.2(2026-08-26)
|
## 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)
|
### 运维脚本:`--table` 让删除目标不再由连接环境决定(issue #18)
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ L3 不是测试的问题,是脚本契约的问题——它同时是生产风
|
|||||||
| 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款;SQLite 库文件即目标,无 schema 概念,无歧义可消 |
|
| 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款;SQLite 库文件即目标,无 schema 概念,无歧义可消 |
|
||||||
| 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**,提示写成 `schema.表名` | 单段等于没声明,隐式性原样保留 |
|
| 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**,提示写成 `schema.表名` | 单段等于没声明,隐式性原样保留 |
|
||||||
| **表名段必须逐字等于 `llm_calls`** | `--table audit.events` → 退出 **1**,消息点明本脚本只清理 `llm_calls` | 见 §4.4:不加这条,`--table` 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具" |
|
| **表名段必须逐字等于 `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 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 |
|
| **逐字比较,不做大小写折叠** | 传 `_quote()` 包裹的限定名给 `to_regclass` | catalog 里存的是真实标识符;未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 |
|
||||||
| 解析不到 | 退出 **2**,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**:诊断方向不同。**退出码维持 2 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 |
|
| 解析不到 | 退出 **2**,消息点名"显式指定的表 X 不存在",并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**:诊断方向不同。**退出码维持 2 而非 1**:`Public.llm_calls` 格式合法,找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化,不由退出码 |
|
||||||
| 无权限 | 后续 `COUNT` 抛 `PostgresError` → 既有 except → 退出 **2** | 无需新增分支 |
|
| 无权限 | 后续 `COUNT` 抛 `PostgresError` → 既有 except → 退出 **2** | 无需新增分支 |
|
||||||
@@ -193,6 +193,8 @@ tests/ 下不得出现字面量 public.llm_calls —— 命中即 exit 1
|
|||||||
| fixture / teardown 里用 admin 连接手滑写真表 | admin 连接必须存在(建 schema/角色本身就需要它),权限边界对它无效 | 工厂不把 admin DSN 交给用例;§7.2 的门能拦住字面量形态 |
|
| 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,比残留本身更危险 |
|
| 进程被 `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. 版本号与发布
|
## 10. 版本号与发布
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,22 @@ def _with_search_path(dsn: str, schema: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _as_role(dsn: str, role: str) -> str:
|
def _as_role(dsn: str, role: str) -> str:
|
||||||
"""把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。"""
|
"""把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。
|
||||||
return re.sub(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1)
|
|
||||||
|
**换不掉就报错,绝不原样返回**: `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
|
@pytest.fixture
|
||||||
@@ -89,6 +103,10 @@ async def pg_catalog_probe():
|
|||||||
dsn = _require_admin_dsn()
|
dsn = _require_admin_dsn()
|
||||||
|
|
||||||
async def probe(sql: str, *args: object) -> list[tuple]:
|
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)
|
conn = await asyncpg.connect(dsn, timeout=10)
|
||||||
try:
|
try:
|
||||||
return [tuple(r) for r in await conn.fetch(sql, *args)]
|
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}"
|
f"GRANT {', '.join(grants)} ON ALL TABLES IN SCHEMA {schema} TO {role_name}"
|
||||||
)
|
)
|
||||||
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
|
# 关键: 绝不 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:
|
except BaseException:
|
||||||
await _unwind(local)
|
await _unwind(local)
|
||||||
raise
|
raise
|
||||||
cleanups.extend(local)
|
cleanups.extend(local)
|
||||||
|
|
||||||
used = bare if role_name is not None else admin_dsn
|
return sandbox
|
||||||
return PgSandbox(
|
|
||||||
schema=schema,
|
|
||||||
role=role_name,
|
|
||||||
dsn=_with_search_path(used, schema),
|
|
||||||
bare_dsn=bare,
|
|
||||||
)
|
|
||||||
|
|
||||||
yield make
|
yield make
|
||||||
await _unwind(cleanups)
|
await _unwind(cleanups)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.integration.conftest import _as_role
|
||||||
|
|
||||||
_DDL = "CREATE TABLE llm_calls (call_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())"
|
_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()
|
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:
|
class TestSchemaOnlySandbox:
|
||||||
async def test_table_lands_in_the_sandbox_schema_and_bare_dsn_is_absent(self, pg_sandbox):
|
async def test_table_lands_in_the_sandbox_schema_and_bare_dsn_is_absent(self, pg_sandbox):
|
||||||
"""`role="none"`: 表落在自建 schema 下;不发角色,故没有裸 DSN 可给。"""
|
"""`role="none"`: 表落在自建 schema 下;不发角色,故没有裸 DSN 可给。"""
|
||||||
|
|||||||
@@ -360,6 +360,59 @@ class TestUsageErrors:
|
|||||||
assert "llm_calls" in result.stderr
|
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:
|
class TestHelp:
|
||||||
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
|
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
|
||||||
"""帮助文本是运维唯一会读的文档,权限口径与"推荐不是 DELETE"必须在里面。"""
|
"""帮助文本是运维唯一会读的文档,权限口径与"推荐不是 DELETE"必须在里面。"""
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
@@ -30,6 +31,11 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
TABLE = "llm_calls"
|
TABLE = "llm_calls"
|
||||||
|
|
||||||
|
# --table 的 schema 段白名单。收紧到普通标识符不是为了防注入(目标名走 to_regclass
|
||||||
|
# 的参数化占位,且用 _quote 转义),而是让 --help 里"不支持复杂标识符"这句话与实现
|
||||||
|
# 一致——文档说不支持、实现却照单全收,受害的是照文档做判断的人。
|
||||||
|
_PLAIN_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
|
||||||
|
|
||||||
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
|
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
|
||||||
EXIT_OK = 0
|
EXIT_OK = 0
|
||||||
EXIT_USAGE = 1
|
EXIT_USAGE = 1
|
||||||
@@ -195,12 +201,13 @@ def _parse_table(parser: _Parser, value: str) -> str:
|
|||||||
if len(segments) != 2:
|
if len(segments) != 2:
|
||||||
parser.error(f"--table 必须是 <schema>.{TABLE} 这样的两段限定名,当前: {value!r}")
|
parser.error(f"--table 必须是 <schema>.{TABLE} 这样的两段限定名,当前: {value!r}")
|
||||||
schema, name = segments
|
schema, name = segments
|
||||||
# 段内不可能再含 "." (上面按 "." 切成恰好两段),故此处只需查引号
|
# 段内不可能再含 "." (上面按 "." 切成恰好两段),故此处只查其余形态
|
||||||
if not schema or not name:
|
if not schema or not name:
|
||||||
parser.error(f"--table 的 schema 段与表名段都不得为空,当前: {value!r}")
|
parser.error(f"--table 的 schema 段与表名段都不得为空,当前: {value!r}")
|
||||||
if '"' in schema or '"' in name:
|
if not _PLAIN_IDENTIFIER.fullmatch(schema):
|
||||||
parser.error(
|
parser.error(
|
||||||
f"--table 不支持含引号的复杂标识符,当前: {value!r};"
|
f"--table 的 schema 段只接受普通标识符(字母或下划线开头,其后字母/数字/"
|
||||||
|
f"下划线/$),当前: {value!r};含空格、引号等需要加引号的复杂标识符不支持,"
|
||||||
"这种情形请不给 --table,退回 search_path 解析那条路径。"
|
"这种情形请不给 --table,退回 search_path 解析那条路径。"
|
||||||
)
|
)
|
||||||
if name != TABLE:
|
if name != TABLE:
|
||||||
|
|||||||
Reference in New Issue
Block a user