给 Agent 的 SQLite 报表工具划定只读边界

从一份包裹统计报表出发,验证只读打开模式限制了什么,再把工具收窄到固定查询和可检查的运行预算。

1. 一个报表工具需要多大的数据库能力

给 Agent 接一个物流报表工具,需求很简单:按地区统计已发送或待发送的包裹数量与单位数。表里还放着内部备注,另一张表保存了不属于报表的标签。工具要返回统计值,却没有理由把整份数据库交给调用方。

实现时很容易先写一个接受 sql 字符串的入口,再补上 SELECT 开头、LIMIT 和只读连接。本文从这个小报表出发,逐项验证这些措施到底限制了什么,最后把入口收窄到 state 与 limit 两个参数。所有例子只使用自己生成的临时库;敏感列里的文字也是 synthetic-note-A 这样的合成标记。

实验运行于 Python 3.12.14、SQLite 3.53.1,23 项断言通过。没有连接真实 MCP 服务或模型,没有读取用户数据库、系统数据库、凭据或外部文件,也没有加载扩展。本文提供的是本地执行边界实验,真实用户认证、租户隔离与部署安全需要另行实现。

模型仅提供 state 和 limit;宿主固定数据库与 SQL,连接层再检查表、列、函数及运行预算。
图 1 模型仅提供 state 和 limit;宿主固定数据库与 SQL,连接层再检查表、列、函数及运行预算。

2. 先把“只读”的含义拆到可验证的操作

SQLite URI 文档定义了 mode=ro 这一打开模式。用它打开本例数据库后,主库 INSERT 会报 attempt to write a readonly database。接着查询 private_note,却仍能得到合成备注;这次读取没有写入主库,因此没有触碰这条限制。

同一连接还能创建 TEMP 表并写入一个数字,也能 ATTACH 一个全新的 :memory: 数据库。后者仅附加空内存库,测试没有尝试读取任何已有文件。这里的观察提醒我们检查连接能力,而不能从一次 INSERT 被拒绝推导出所有操作都已受控。

还有一种边界藏在宿主注册的函数里。反例注册 remember,把传入的 synthetic 追加到 Python list,再返回原值。SELECT remember('synthetic') LIMIT 1 成功执行,list 中增加一个元素。副作用只发生在当前进程的普通列表中,足以说明 SQL 文本的开头和结果行数不能约束函数体。

sqlite3_stmt_readonly 文档同样区分直接数据库修改与函数、虚拟表带来的间接作用。本文没有调用该 C API,也没有验证虚拟表,只借它解释“语句只读”一词的官方边界。准备接入用户定义函数、扩展或非可信数据库文件时,应分别审查实现与来源,不能把已有测试成绩直接移植过去。

合成反例显示:主库只读仍可读私密列、使用 TEMP、附加空内存库及执行已注册函数。
图 2 合成反例显示:主库只读仍可读私密列、使用 TEMP、附加空内存库及执行已注册函数。

3. 固定工具合同,参数只表示值

报表的 SQL、表名、列名、排序以及数据库路径都由宿主固定。state 先检查精确字符串类型,拒绝 null、布尔、数字、数组和对象;调用方只能选择 queued 或 sent,再给出 1 到 20 的整数 limit。布尔值在 Python 中属于 int 的子类,所以示例使用 type(limit) is int,避免把 True 当作 1 接受。非法选项在数据库执行前抛出 ValueError。

state 与 limit 使用问号占位符。测试把看似 SQL 的字符串作为 state 绑定到查询中,结果为空;那段字符串没有成为语法。另一个测试执行 SELECT ? FROM parcels,绑定 units 后拿到的是字符串 units,而不是 units 列里的数字。SQL 表达式文档中的占位符表示运行时值,不能用来选择列或表。需要可选排序时,应由宿主把小型枚举映射到预先审核的固定 SQL。

因此,下面的 report_tool 不是一个通用数据库终端。测试文件会直接执行若干 SQL 来观察连接防护,但模型入口只应暴露 report_tool 的业务参数。path 出现在 Python 函数签名里,是为了让临时 fixture 能传入路径;真实工具注册时必须由可信宿主闭包或配置绑定它,不能把 path 加进模型参数模式。

4. 让 authorizer 看见表、列与函数

连接采用默认拒绝:仅放行 SELECT、main.parcels 上的 zone、units、state,以及 count、sum 函数。READ 回调的空列名用于表级读取情形,不表示开放全部列;本例仍要求 main 数据库和 parcels 表。其他表、private_note、写操作、PRAGMA、ATTACH、TEMP DDL、扩展函数及 random 都被测试为拒绝。

SQLite authorizer在准备语句及必要的重新准备阶段检查操作。回调持续保留到连接关闭;它没有拿到本次业务用户身份,也不会逐行执行租户鉴权。本文选 SQLITE_DENY 使整条语句失败,未采用会让部分读取被替换为 NULL 的 SQLITE_IGNORE。

每次工具调用新建连接,并将 cached_statements 设为 0,省去教学代码中动态权限与语句复用的额外状态。宿主仍掌握连接对象,能够移除回调或注册同名函数。允许 sum 这个名称的前提包含宿主没有把它替换成另一种有副作用的实现;该连接不能作为能力沙箱交给不可信 Python 代码。

本例在安装回调前开启 query_only、关闭 trusted_schema,显式关闭扩展加载,再缩小 SQL 长度、值长度与附加库数量上限。这些是附加措施,不应拿来替代表列允许名单;可对照 query_only 说明与 SQLite 安全建议检查各层职责。Python 的扩展支持取决于构建;如果 enable_load_extension 不存在或调用失败,应停止并检查运行环境,不能为了继续演示而静默跳过。参考 Python sqlite3 API核对本机能力。

5. 保留一个真实的兼容性边缘:COUNT(*)

实验专门记录独立语句 SELECT COUNT(*) FROM parcels 的 READ 回调。本机观察到参数为 (20, 'parcels', '', None, None):20 对应 SQLITE_READ,列名为空,数据库名为 None。严格要求 database == 'main' 的回调因此拒绝这条语句。完整 GROUP BY 报表中的 COUNT(*) 则成功运行,两者不能只凭表面相似就断言行为相同。

测试会先观察参数,只有当前构建确实给出缺失数据库名时,才检查这条失败关闭分支;其他构建的检查总数可能不同。官方文档要求调用方能处理回调中的空指针,但不能据此把所有 database=None 请求都列入允许名单。

本例还验证 COUNT(units) 可以执行。换用显式列计数必须检查语义:它忽略 NULL,而 COUNT(*) 统计行。本 fixture 的 units 没有 NULL,所以这里得到相同的 3;真实数据不能直接照搬这一替换。若报表以后改变,先用实际 SQL 和目标 SQLite 构建补测试,再决定是否扩展规则,遇到不认识的情况继续拒绝。

6. LIMIT 管输出,progress 管一部分运行预算

向自建表追加一万条合成记录后,测试执行 SELECT SUM(units) FROM parcels LIMIT 1。虽然最多返回一行,SUM 仍需处理聚合输入。将 progress 间隔设为约 100 条 VM 指令,在第 20 次回调返回非零,这条查询报 interrupted。它证明输出行数无法替代执行预算,没有比较索引方案,也没有把数字包装成性能结论。

progress handler 文档把回调间隔定义为近似 VM 指令数,非零返回值用于中断。正式示例选择每约 100 条指令检查一次,到第 1000 次回调中断,约十万条指令只是教学阈值。它并非精确计数器;SQLite 3.41 起,复杂语句准备阶段也可能调用回调。

另一个反例在未防护连接注册一个只 sleep 约 30ms 的函数。间隔设为 1000 时,函数执行完毕,回调次数仍是零。函数体消耗的墙钟时间与 SQLite 指令推进不是同一件事。工具连接没有注册这个函数;它只是说明本实现没有硬墙钟 deadline。

Python connect 的 timeout 主要控制锁等待,不是整个查询的总截止时间。需要更强的运行约束时,应另行设计进程级资源隔离、可靠终止与结果交付流程。本例也未实现总响应字节上限、全应用速率限制和内存隔离;LIMIT 限的是分组数,单个值的长度限制也不能代替总响应大小预算。

聚合在第 20 次回调被中断;30ms 函数可在零次回调时结束。数字来自自建库实验。
图 3 聚合在第 20 次回调被中断;30ms 函数可在零次回调时结束。数字来自自建库实验。

7. 从空目录复跑

2026-10-14 清理路径修正:连接打开后,如果 PRAGMA、扩展开关、限额或 authorizer 安装失败,原 connection() 会抛出异常,却没有显式关闭已打开的连接。现在只在初始化区间捕获失败,先尝试 close,再重新抛出原异常;成功 return 后清理由调用方负责。原 report_tool 的 progress 注册也移入受保护区间。若失败清理本身再次报错,本例保留原异常;这表示已经尝试清理,不保证失败的 close 仍能关闭资源。固定 SQL、参数、允许名单、预算与报表输出不变。Python close 文档说明显式关闭连接;连接的 with 块只管理事务,不自动关闭连接。

准备一个空目录,复制本节后面的四个 Python 文件。它们只依赖标准库,不需要安装包或填写密钥。建议使用 Python 3.12 或更新版本,并先记录实际 SQLite 版本。测试仅创建 TemporaryDirectory 内的原创数据,运行结束后清理;请保持这条路径纪律,不要把示例改指向自己的业务数据库。

python -B -c "import platform, sqlite3; print(platform.python_version(), sqlite3.sqlite_version)"
python -B demo.py
python -B test_readonly.py

demo.py 的 rows 应为 east: 1 件、2 单位,以及 west: 1 件、3 单位。progress_callbacks 的数值与 SQLite 构建有关,不应作为业务结果。test_readonly.py 会输出版本、每项名称、通过状态和观察值;断言失败会终止运行。无需逐字比较耗时、错误消息或回调细节,先核对版本与具体断言。

保存为 fixture.py:

"""Create only a new synthetic fixture inside a caller-owned temporary directory."""
import sqlite3

def create_fixture(path):
    # Refuse an existing path; callers in this tutorial own the fresh directory.
    if path.exists():
        raise FileExistsError(path)
    db = sqlite3.connect(path)
    try:
        db.executescript("CREATE TABLE parcels(zone TEXT,units INTEGER,state TEXT,private_note TEXT); CREATE TABLE hidden(label TEXT);")
        db.executemany('INSERT INTO parcels VALUES (?,?,?,?)', [
            ('east', 2, 'sent', 'synthetic-note-A'),
            ('west', 3, 'sent', 'synthetic-note-B'),
            ('east', 4, 'queued', 'synthetic-note-C'),
        ])
        db.execute("INSERT INTO hidden VALUES ('synthetic-hidden')")
        db.commit()
    finally:
        db.close()

保存为 readonly_tool.py:

"""Fixed, host-configured analysis tool. No raw SQL argument."""
import sqlite3
from pathlib import Path

READ_COLUMNS = {'zone', 'units', 'state'}
REPORT = '''SELECT zone, COUNT(*) AS parcels, SUM(units) AS units
            FROM parcels WHERE state = ?
            GROUP BY zone ORDER BY zone LIMIT ?'''

def authorizer(action, arg1, arg2, database, source):
    if source is not None:  # This example does not need views/triggers.
        return sqlite3.SQLITE_DENY
    if action == sqlite3.SQLITE_SELECT:
        return sqlite3.SQLITE_OK
    if action == sqlite3.SQLITE_READ:
        # Empty column means table access such as COUNT(*), not every column.
        # A missing database name still fails closed; observed on 3.53.1.
        return sqlite3.SQLITE_OK if (database == 'main' and arg1 == 'parcels'
                                     and arg2 in READ_COLUMNS | {''}) else sqlite3.SQLITE_DENY
    if action == sqlite3.SQLITE_FUNCTION:
        return sqlite3.SQLITE_OK if arg2 in {'count', 'sum'} else sqlite3.SQLITE_DENY
    return sqlite3.SQLITE_DENY

def connection(path, guarded=False):
    db = sqlite3.connect(path.as_uri() + '?mode=ro', uri=True,
                         isolation_level=None, cached_statements=0)
    try:
        if guarded:
            db.execute('PRAGMA query_only=ON')
            db.execute('PRAGMA trusted_schema=OFF')
            db.enable_load_extension(False)
            db.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, 8000)
            db.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, 100000)
            db.setlimit(sqlite3.SQLITE_LIMIT_ATTACHED, 0)
            db.set_authorizer(authorizer)
    except BaseException:
        # Setup owns the connection until successful return. Preserve its error.
        try:
            db.close()
        except BaseException:
            pass
        raise
    return db

def report_tool(path, state, limit=10):
    # path is host configuration, never a model argument; no raw-SQL input.
    if type(state) is not str or state not in {'queued', 'sent'} or type(limit) is not int or not 1 <= limit <= 20:
        raise ValueError('invalid query options')
    db = connection(path, guarded=True)
    calls = 0
    def progress():
        nonlocal calls
        calls += 1
        return calls >= 1000
    try:
        db.set_progress_handler(progress, 100)
        rows = db.execute(REPORT, (state, limit)).fetchall()
    except BaseException:
        # Registration/query failures keep their original exception.
        try:
            db.close()
        except BaseException:
            pass
        raise
    else:
        db.close()
        return {'rows': rows, 'progress_callbacks': calls}

保存为 demo.py:

import json
import tempfile
from pathlib import Path
from fixture import create_fixture
from readonly_tool import report_tool

with tempfile.TemporaryDirectory(prefix='readonly-demo-') as folder:
    path = Path(folder) / 'synthetic.sqlite'
    create_fixture(path)
    print(json.dumps(report_tool(path, 'sent'), ensure_ascii=False, indent=2))

保存为 expected-demo.json:

{
  "rows": [
    [
      "east",
      1,
      2
    ],
    [
      "west",
      1,
      3
    ]
  ],
  "progress_callbacks": 0
}

8. 独立测试:同时检查成功路径与拒绝路径

以下测试单独导入已保存的 fixture 与工具模块,不把结果预先写成“通过”。拒绝测试只有捕获 DatabaseError 才成功;SQL 意外执行将抛出 AssertionError。测试连接包含无防护反例与加固连接两组,阅读时注意它们的用途。

八个拒绝用例验证的是组合连接策略的可见结果,不能据此把每次拒绝都单独归因于 authorizer:query_only、附加库上限及扩展开关也可能参与限制。测试名称保留 authorizer_denies 前缀以标记这组策略,但没有隔离测量每一层的贡献。

保存为 test_readonly.py:

"""Independent assertions against the shipped tool and synthetic fixture."""
import json
import platform
import sqlite3
import tempfile
import time
from pathlib import Path
from fixture import create_fixture
from readonly_tool import connection, report_tool, REPORT

def run():
    results = {'python': platform.python_version(), 'sqlite': sqlite3.sqlite_version, 'checks': []}
    def check(name, condition, observation):
        assert condition, (name, observation)
        results['checks'].append({'name': name, 'pass': True, 'observation': observation})
    def denied(db, sql, params=()):
        try:
            db.execute(sql, params).fetchall()
        except sqlite3.DatabaseError as e:
            return str(e)
        raise AssertionError('Unexpectedly allowed: ' + sql)
    with tempfile.TemporaryDirectory(prefix='sql-ro-probe-') as folder:
        path = Path(folder) / 'synthetic.sqlite'
        create_fixture(path)
        db = connection(path)
        check('mode_ro_rejects_main_insert', True, denied(db, "INSERT INTO parcels VALUES ('x',1,'sent','synthetic')"))
        check('mode_ro_can_read_private_column', db.execute('SELECT private_note FROM parcels LIMIT 1').fetchone() == ('synthetic-note-A',), 'SELECT + LIMIT still reads synthetic private column')
        db.execute('CREATE TEMP TABLE scratch(x)')
        db.execute('INSERT INTO temp.scratch VALUES (1)')
        check('mode_ro_allows_temp_write', db.execute('SELECT * FROM temp.scratch').fetchall() == [(1,)], 'Only in-memory/temp teaching table modified')
        db.execute("ATTACH DATABASE ':memory:' AS extra")
        check('mode_ro_allows_attach_memory', any(r[1] == 'extra' for r in db.execute('PRAGMA database_list')), 'Attached only empty :memory: database')
        effects = []
        db.create_function('remember', 1, lambda v: effects.append(v) or v)
        sql = "SELECT remember('synthetic') LIMIT 1"
        db.execute(sql).fetchall()
        check('select_prefix_and_limit_allow_python_side_effect', sql.upper().startswith('SELECT') and effects == ['synthetic'], 'Effect only appends to a Python list')
        check('placeholder_is_value_not_identifier', db.execute('SELECT ? FROM parcels LIMIT 1', ('units',)).fetchone() == ('units',), 'Returns string units, not numeric column value')
        calls = [0]
        db.set_progress_handler(lambda: calls.__setitem__(0, calls[0]+1) or 0, 1000)
        db.create_function('pause_briefly', 0, lambda: time.sleep(0.03) or 1)
        start=time.monotonic();db.execute('SELECT pause_briefly()').fetchall();elapsed=time.monotonic()-start
        check('progress_not_wall_clock_deadline', elapsed >= 0.025 and calls[0] == 0, {'elapsed_seconds': round(elapsed,4), 'callbacks': calls[0]})
        db.close()
        observed=[]
        db=connection(path)
        db.set_authorizer(lambda *args: observed.append(args) or sqlite3.SQLITE_OK)
        db.execute('SELECT COUNT(*) FROM parcels').fetchall()
        count_reads=[x for x in observed if x[0]==sqlite3.SQLITE_READ]
        check('count_star_callback_observation', bool(count_reads), count_reads)
        db.close()
        db = connection(path, True)
        for name, sql in [
            ('private_column', 'SELECT private_note FROM parcels LIMIT 1'),
            ('hidden_table', 'SELECT label FROM hidden'),
            ('write', "UPDATE parcels SET units=0"),
            ('pragma', 'PRAGMA query_only=OFF'),
            ('attach', "ATTACH DATABASE ':memory:' AS extra"),
            ('extension_function', "SELECT load_extension('never-loaded')"),
            ('non_allowlisted_function', "SELECT random()"),
            ('temp_ddl', 'CREATE TEMP TABLE scratch(x)')]:
            check('authorizer_denies_'+name, True, denied(db, sql))
        if any(x[3] is None for x in count_reads):
            check('count_star_without_db_name_fails_closed', True, denied(db, 'SELECT COUNT(*) FROM parcels'))
        check('explicit_column_count_allowed', db.execute('SELECT COUNT(units) FROM parcels').fetchone() == (3,), 'Use an explicitly authorized column when semantically suitable')
        db.close()
        for bad_state in (None, True, 1, [], {}):
            try:
                report_tool(path, bad_state)
            except ValueError:
                pass
            else:
                raise AssertionError('non-string state accepted')
        check('tool_rejects_non_string_state', True, 'null, boolean, number, array, object all raise ValueError')
        good = report_tool(path, 'sent')
        check('fixed_report_result', good['rows']==[('east',1,2),('west',1,3)], good)
        try: report_tool(path, "sent' OR 1=1 --")
        except ValueError: check('tool_rejects_unrecognized_state', True, 'Enum validation fails before SQL')
        else: raise AssertionError('invalid state accepted')
        db = connection(path, True)
        check('bound_value_has_no_sql_syntax', db.execute(REPORT, ("sent' OR 1=1 --",10)).fetchall() == [], 'Bound unmatched state returns zero rows')
        db.close()
        # Populate only our newly created synthetic fixture; never an existing DB.
        db=sqlite3.connect(path);db.executemany('INSERT INTO parcels VALUES (?,?,?,?)',[('east',1,'sent','synthetic')]*10000);db.commit();db.close()
        db=connection(path, True)
        calls=[0]
        def budget():
            calls[0]+=1
            return calls[0]>=20
        db.set_progress_handler(budget,100)
        error=denied(db, 'SELECT SUM(units) FROM parcels LIMIT 1')
        check('limit_one_aggregate_exceeds_step_budget', calls[0]==20 and error=='interrupted', {'callbacks':calls[0],'error':error,'interval_approx_vm_instructions':100})
        db.close()
    return results

if __name__ == '__main__':
    print(json.dumps(run(), ensure_ascii=False, indent=2))

以下是 2026-10-14 新增的初始化失败回归检查。通过 Connection 子类在自建临时库上注入 PRAGMA、扩展开关、限额、authorizer 和 progress 注册异常,检查原异常对象仍被抛出,且 close 恰好调用一次;关闭成功时连接不可再用。另注入 close 自身失败,测试只要求保留原异常与清理尝试,并由测试最后用基类关闭 fixture。它没有测试真实不支持扩展的构建、模型调用或生产环境。旧代码运行这 5 个测试方法产生 12 处失败;本次修正后 5 个方法全部通过,原 23 项检查、整数报表的 19 个方法及 8 个错误版本检查仍通过。整数报表测试中的依赖文件 SHA-256 随这次源码修正同步更新,业务断言不变。

保存为 test_connection_cleanup.py:

"""Injected lifecycle failures on fresh local synthetic SQLite connections only."""
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import exact_report
import readonly_tool
from fixture import create_fixture

REAL_CONNECT = sqlite3.connect

class CleanupTests(unittest.TestCase):
    def exercise(self, entry, stage, close_fails=False, error_type=sqlite3.OperationalError):
        original = error_type('synthetic ' + stage + ' failure')
        opened = []
        class Connection(sqlite3.Connection):
            close_calls = 0
            def execute(self, sql, *args):
                if stage == 'pragma' and sql == 'PRAGMA query_only=ON':
                    raise original
                return super().execute(sql, *args)
            def enable_load_extension(self, enabled):
                if stage == 'extension':
                    raise original
                return super().enable_load_extension(enabled)
            def setlimit(self, category, limit):
                if stage == 'limit':
                    raise original
                return super().setlimit(category, limit)
            def set_authorizer(self, callback):
                if stage == 'authorizer':
                    raise original
                return super().set_authorizer(callback)
            def set_progress_handler(self, callback, interval):
                if stage == 'progress':
                    raise original
                return super().set_progress_handler(callback, interval)
            def close(self):
                self.close_calls += 1
                if close_fails:
                    # Failure occurs before underlying close: verify attempted cleanup,
                    # not a promise that failing cleanup can close the handle.
                    raise RuntimeError('synthetic close failure')
                return super().close()
        def connect(*args, **kwargs):
            db = REAL_CONNECT(*args, factory=Connection, **kwargs)
            opened.append(db)
            return db
        with tempfile.TemporaryDirectory(prefix='synthetic-cleanup-') as folder:
            path = Path(folder) / 'synthetic.sqlite'
            create_fixture(path)
            try:
                with patch.object(readonly_tool.sqlite3, 'connect', connect):
                    with self.assertRaises(error_type) as caught:
                        entry(path, 'sent')
                self.assertIs(caught.exception, original)
                self.assertEqual(len(opened), 1)
                self.assertEqual(opened[0].close_calls, 1)
                if not close_fails:
                    with self.assertRaises(sqlite3.ProgrammingError):
                        opened[0].execute('SELECT 1')
            finally:
                for db in opened:
                    # Test-owned cleanup even when overridden close failed.
                    sqlite3.Connection.close(db)

    def test_guard_setup_closes_for_both_entrypoints(self):
        for entry in (readonly_tool.report_tool, exact_report.tool_result):
            for stage in ('pragma', 'extension', 'limit', 'authorizer'):
                with self.subTest(entry=entry.__name__, stage=stage):
                    self.exercise(entry, stage)

    def test_guard_cleanup_failure_preserves_original(self):
        for entry in (readonly_tool.report_tool, exact_report.tool_result):
            with self.subTest(entry=entry.__name__):
                self.exercise(entry, 'extension', close_fails=True)

    def test_original_report_registration_closes(self):
        self.exercise(readonly_tool.report_tool, 'progress')
        self.exercise(readonly_tool.report_tool, 'progress', close_fails=True)

    def test_base_exception_during_setup_closes(self):
        self.exercise(readonly_tool.report_tool, 'extension', error_type=KeyboardInterrupt)

    def test_successful_ownership_transfer(self):
        with tempfile.TemporaryDirectory(prefix='synthetic-cleanup-') as folder:
            path = Path(folder) / 'synthetic.sqlite'
            create_fixture(path)
            db = readonly_tool.connection(path, guarded=True)
            try:
                self.assertEqual(db.execute('SELECT units FROM parcels WHERE state=?', ('sent',)).fetchall(), [(2,), (3,)])
            finally:
                db.close()
            self.assertEqual(readonly_tool.report_tool(path, 'sent')['rows'], [('east', 1, 2), ('west', 1, 3)])

if __name__ == '__main__':
    unittest.main(verbosity=2)

运行新增检查:

python -B -m unittest -v test_connection_cleanup.py

9. 本次运行记录与还没有验证的范围

下面保存的是本次重跑输出。版本是 Python 3.12.14 / SQLite 3.53.1,23 项检查均通过。睡眠耗时属于单次观察,不是性能基准或保证;读者机器上的浮点时间和某些错误文字可以不同。

保存为 expected-results.json:

{
  "python": "3.12.14",
  "sqlite": "3.53.1",
  "checks": [
    {
      "name": "mode_ro_rejects_main_insert",
      "pass": true,
      "observation": "attempt to write a readonly database"
    },
    {
      "name": "mode_ro_can_read_private_column",
      "pass": true,
      "observation": "SELECT + LIMIT still reads synthetic private column"
    },
    {
      "name": "mode_ro_allows_temp_write",
      "pass": true,
      "observation": "Only in-memory/temp teaching table modified"
    },
    {
      "name": "mode_ro_allows_attach_memory",
      "pass": true,
      "observation": "Attached only empty :memory: database"
    },
    {
      "name": "select_prefix_and_limit_allow_python_side_effect",
      "pass": true,
      "observation": "Effect only appends to a Python list"
    },
    {
      "name": "placeholder_is_value_not_identifier",
      "pass": true,
      "observation": "Returns string units, not numeric column value"
    },
    {
      "name": "progress_not_wall_clock_deadline",
      "pass": true,
      "observation": {
        "elapsed_seconds": 0.0302,
        "callbacks": 0
      }
    },
    {
      "name": "count_star_callback_observation",
      "pass": true,
      "observation": [
        [
          20,
          "parcels",
          "",
          null,
          null
        ]
      ]
    },
    {
      "name": "authorizer_denies_private_column",
      "pass": true,
      "observation": "access to parcels.private_note is prohibited"
    },
    {
      "name": "authorizer_denies_hidden_table",
      "pass": true,
      "observation": "access to hidden.label is prohibited"
    },
    {
      "name": "authorizer_denies_write",
      "pass": true,
      "observation": "not authorized"
    },
    {
      "name": "authorizer_denies_pragma",
      "pass": true,
      "observation": "not authorized"
    },
    {
      "name": "authorizer_denies_attach",
      "pass": true,
      "observation": "not authorized"
    },
    {
      "name": "authorizer_denies_extension_function",
      "pass": true,
      "observation": "not authorized to use function: load_extension"
    },
    {
      "name": "authorizer_denies_non_allowlisted_function",
      "pass": true,
      "observation": "not authorized to use function: random"
    },
    {
      "name": "authorizer_denies_temp_ddl",
      "pass": true,
      "observation": "not authorized"
    },
    {
      "name": "count_star_without_db_name_fails_closed",
      "pass": true,
      "observation": "not authorized"
    },
    {
      "name": "explicit_column_count_allowed",
      "pass": true,
      "observation": "Use an explicitly authorized column when semantically suitable"
    },
    {
      "name": "tool_rejects_non_string_state",
      "pass": true,
      "observation": "null, boolean, number, array, object all raise ValueError"
    },
    {
      "name": "fixed_report_result",
      "pass": true,
      "observation": {
        "rows": [
          [
            "east",
            1,
            2
          ],
          [
            "west",
            1,
            3
          ]
        ],
        "progress_callbacks": 0
      }
    },
    {
      "name": "tool_rejects_unrecognized_state",
      "pass": true,
      "observation": "Enum validation fails before SQL"
    },
    {
      "name": "bound_value_has_no_sql_syntax",
      "pass": true,
      "observation": "Bound unmatched state returns zero rows"
    },
    {
      "name": "limit_one_aggregate_exceeds_step_budget",
      "pass": true,
      "observation": {
        "callbacks": 20,
        "error": "interrupted",
        "interval_approx_vm_instructions": 100
      }
    }
  ]
}

这个报表适用于可信宿主管理的普通合成表,证明了指定参数、固定 SQL 与连接防护可以共同约束本地操作。它没有验证恶意数据库文件、定制虚拟表、真实 UDF 插件、WAL 或并发写入、跨平台构建,也没有测试模型是否会正确调用工具。HTML 与 SVG 做了结构检查,未做浏览器像素验收。

如果数据来自多个用户,入口还必须从可信认证信息得到身份,并实施行级授权谓词或分库等措施。即使只返回聚合数字,也需要判断调用方是否有权看到该统计范围。数据库只读打开模式不会代替这一步。

也不要用 immutable=1 作为“更安全”的开关。该参数向 SQLite 声明文件不会发生变化;文件实际上仍被别处修改时,这个前提会破坏读取正确性。文件来源、宿主函数与扩展代码应分别审查,不能从合成库的 23 项检查推出生产沙箱保证。

实际接线时,保留本例的窄入口,先确认固定查询在目标版本与真实 schema 上的结果,再补上身份、返回大小、并发与终止测试。每增加一个表、函数或查询形状,都应把相应允许条件和拒绝用例一起交付。

10. 补充实验:报表返回的单位数从哪里来

上面的固定查询把可执行操作收窄了。接下来给同一张 parcels 表加入一条 units 为字符串 pending 的合成记录,再调用既有 report_tool。查询成功,east 的件数为 1,单位数却是 0.0。表定义中的 INTEGER 没有拒绝这次写入,SUM 也没有替业务判断 pending 能否参与统计。

本节在 2026-10-13 用 Python 3.12.14 / SQLite 3.53.1 补跑这一类输入。2026-10-13 补充时保留了原有九节、四个 Python 文件、运行输出及其日期;2026-10-14 仅修正前述连接清理路径,并同步依赖文件校验;新增 exact_report.py 给“可交付的整数单位报表”写一份更窄的数据合同。所有写入仍只发生在新建临时目录的合成库中。

SQLite 类型亲和文档说明,普通表的 INTEGER 亲和会尝试转换值,列内仍可能留下 TEXT、REAL、NULL 或 BLOB。下面每一项都是独立创建的数据库,其他列固定为 state=sent、private_note=synthetic-unused。原输出中的三元数组依次是地区、件数、单位数。

相同合成输入上的原查询与新增合同,原查询使用默认 limit=10
写入输入units 存储类型原 report_tool新 tool_result
无匹配行无[]{"status":"ok","rows":[]}
east: 0integer × 1[["east", 1, 0]]{"status":"ok","rows":[{"zone":"east","parcels":1,"units_decimal":"0"}]}
east: NULLnull × 1[["east", 1, null]]{"status":"rejected","reason":"missing_units"}
east: 'pending'text × 1[["east", 1, 0.0]]{"status":"rejected","reason":"invalid_units_type"}
east: '12units'text × 1[["east", 1, 12.0]]{"status":"rejected","reason":"invalid_units_type"}
east: 0.5real × 1[["east", 1, 0.5]]{"status":"rejected","reason":"invalid_units_type"}
east: BLOB x'3132'blob × 1[["east", 1, 12.0]]{"status":"rejected","reason":"invalid_units_type"}
zone: NULL;units: 1integer × 1[[null, 1, 1]]{"status":"rejected","reason":"invalid_zone"}
east: -1integer × 1[["east", 1, -1]]{"status":"rejected","reason":"invalid_units_range"}
east: '0007'integer × 1[["east", 1, 7]]{"status":"ok","rows":[{"zone":"east","parcels":1,"units_decimal":"7"}]}
east: 9007199254740993integer × 1[["east", 1, 9007199254740993]]{"status":"ok","rows":[{"zone":"east","parcels":1,"units_decimal":"9007199254740993"}]}
east: 9223372036854775807;east: 1integer × 2OperationalError: integer overflow{"status":"rejected","reason":"sum_out_of_range"}
east: 2;west: 'pending'integer × 1, text × 1[["east", 1, 2], ["west", 1, 0.0]]{"status":"rejected","reason":"invalid_units_type"}
east: 1,共 1001 行integer × 1001[["east", 1001, 1001]]{"status":"rejected","reason":"source_limit"}
zone00 至 zone20,各 1 行、1 单位integer × 21[["zone00", 1, 1], ["zone01", 1, 1], ["zone02", 1, 1], ["zone03", 1, 1], ["zone04", 1, 1], ["zone05", 1, 1], ["zone06", 1, 1], ["zone07", 1, 1], ["zone08", 1, 1], ["zone09", 1, 1]]{"status":"rejected","reason":"group_limit"}

其中 TOTAL 是另开观察连接执行的探针,没有加入原工具的允许名单。单独存入 9007199254740993 时,原 SUM 保留该整数,TOTAL 得到 9007199254740992.0;存入 9223372036854775807 与 1 时,原 SUM 在本机报 integer overflow。SUM / TOTAL 官方说明明确了整数溢出、浮点结果及空输入行为,两个函数的输出合同需要分别验收。

空集合、真实零和缺失单位数也要分开看:GROUP BY 在没有匹配行时产生零组;一行 0 产生一组零;一行 NULL 仍被 COUNT(*) 计为一件,但 SUM 为 NULL。示例观察到 pending 被累计成 0.0,12units 被累计成 12.0。这些成功返回的数值不能替代输入有效性检查。

11. 让完整整数报表有一份明确合同

新增入口只暴露 state。路径仍由可信宿主绑定,SQL 和两个容量常量也由宿主固定。查询沿用原 connection(path, guarded=True),因此没有修改原 authorizer、schema、扩展开关或 report_tool。它读取已允许的 zone、units、state,使用默认 SQLite 到 Python 类型映射,未注册自定义 converter。

  1. zone 是 1 至 80 个字符的字符串。units 当前读出后必须是非负 Python int,单值不超过 9223372036854775807;缺失值、文本、小数与 BLOB 一律拒绝,不做 CAST 或默认补零。
  2. 一次固定 SELECT 最多取得 1001 条匹配记录。出现第 1001 条时整份报告拒绝;1000 条以内才确认本次匹配集合已经完整读出。完整结果最多 20 个地区,超过同样拒绝。
  3. 使用 Python 整数计数、求和,每组总量也限定在 0 至 9223372036854775807。这个总量上限是本例主动选择的业务域;Python 能计算更大的整数,这里的 sum_out_of_range 表示超出合同。
  4. 只有全部检查通过,才生成 status=ok 的完整结果。units_decimal 始终是十进制字符串,包括零。拒绝响应只有 status 和 reason,没有先算出的部分 rows。

这份合同检查的是已存储、再读出的值。写入字符串 0007 后,INTEGER 亲和已把它变成整数 7,所以新增工具接受它。整值浮点数、科学计数法文本或布尔输入也可能在写入前后归一化,甚至已经损失原始精度;读端无法还原这些过程。若业务要禁止某种原始拼写或输入类型,应在入库前验证并保留所需来源。本例对 zone 同样只检查存储后的字符串,按 Python 字符串精确分组及排序,适用于所示可信 schema,未适配自定义排序规则。接线时,调用方要同步采用 status 分支与 rows 中的对象字段,并按 units_decimal 字符串接收总量;原 report_tool 的三元组接口继续保留。

1000 是匹配源行上限,SQL 为找齐这些行仍可能扫描更多记录。沿用的 progress handler 继续提供近似 VM 指令预算;它仍不是硬墙钟限制或精确内存上限。SQL 没有 ORDER BY,因为成功路径读取完整的有界集合,聚合完再排序;超过容量只报拒绝,不把任意前缀当作报表。

固定查询取最多 1001 条匹配记录,检查全部存储值与分组总量后才返回完整报表;拒绝不附带小计,数据库失败继续传播。
图 4 精确整数报表的完整交付条件。1000 行与 20 组均为本例选择的业务上限。

保存为 exact_report.py:

"""Bounded synthetic report example; no schema/policy changes to original module.

Input path is trusted host configuration, never an exposed model argument.
Results are all-or-nothing; units_decimal is an exact decimal string.
"""
from collections import defaultdict
from readonly_tool import connection

MAX_I64 = 2**63 - 1
SOURCE_LIMIT = 1000
GROUP_LIMIT = 20
READ = 'SELECT zone, units FROM parcels WHERE state = ? LIMIT ?'


class ReportRejected(ValueError):
    pass


def exact_report(path, state):
    if type(state) is not str or state not in {'queued', 'sent'}:
        raise ReportRejected('invalid_state')
    db = connection(path, guarded=True)
    ticks = 0
    def progress():
        nonlocal ticks
        ticks += 1
        return ticks >= 1000
    try:
        db.set_progress_handler(progress, 100)
        # One SELECT. Fetch one extra row to distinguish complete input from truncation.
        rows = db.execute(READ, (state, SOURCE_LIMIT + 1)).fetchall()
        if len(rows) > SOURCE_LIMIT:
            raise ReportRejected('source_limit')
        totals = defaultdict(lambda: [0, 0])
        # Validate every row before building any successful response.
        for zone, units in rows:
            if type(zone) is not str or not zone or len(zone) > 80:
                raise ReportRejected('invalid_zone')
            if units is None:
                raise ReportRejected('missing_units')
            if type(units) is not int:
                raise ReportRejected('invalid_units_type')
            if not 0 <= units <= MAX_I64:
                raise ReportRejected('invalid_units_range')
            totals[zone][0] += 1
            totals[zone][1] += units
        if len(totals) > GROUP_LIMIT:
            raise ReportRejected('group_limit')
        if any(total > MAX_I64 for _, total in totals.values()):
            raise ReportRejected('sum_out_of_range')
        result = {'status': 'ok', 'rows': [
            {'zone': zone, 'parcels': count, 'units_decimal': str(total)}
            for zone, (count, total) in sorted(totals.items())]}
    except BaseException:
        # Preserve the primary registration/query/validation error if cleanup fails.
        try:
            db.close()
        except BaseException:
            pass
        raise
    else:
        db.close()
        return result


def tool_result(path, state):
    # Database failures deliberately propagate; never relabel them empty-success.
    try:
        return exact_report(path, state)
    except ReportRejected as error:
        return {'status': 'rejected', 'reason': str(error)}

SQLite INTEGER 默认映射为 Python int,REAL 映射为 float,NULL 映射为 None,见 Python sqlite3 类型映射。这也是代码可以直接检查 type(units) 的前提。字符串字段保住交付的十进制字符,接收端还应遵守该字段合同,不能再无条件转为浮点数;这里没有运行浏览器或跨语言客户端实验。

整数报表的失败清理还需覆盖查询主体:若 progress 注册、execute、fetchall 或合同校验已经失败,而 close 随后也失败,单纯的 finally 会让关闭异常成为抛出的异常;原失败虽在异常上下文中,调用方却无法直接按原类型处理。这里让失败分支尝试关闭后重新抛出原异常,成功分支则先关闭再交付结果。成功查询后的 close 失败仍传播,不会交付成功报告。清理失败只表示尝试过关闭,不保证资源已释放;ReportRejected 保持既有拒绝响应合同。

12. 从空目录复跑这组聚合输入

先复制上文四个 Python 文件,再把下列 probe.py、expected.json、fixtures.json、test_exact_report.py、check_mutants.py 和刚才的 exact_report.py 放在同一目录。公开离线包的 source 目录已经包含这些文件,也包含原实验的 fixture.py、demo.py、test_readonly.py 与两份原预期输出。全部仅依赖 Python 标准库。

python -B -c "import platform, sqlite3; print(platform.python_version(), sqlite3.sqlite_version)"
python -B demo.py
python -B test_readonly.py
python -B probe.py
python -B -m unittest -v test_exact_report.py
python -B check_mutants.py

probe.py 在一个 TemporaryDirectory 中逐个生成新库,不会打开已有业务文件。每组数据只改变 zone 与 units;输入常量完整列在文件中。typeof 与 TOTAL 只在未加业务防护的观察连接上使用,随后才调用原报表与新增入口,因此没有为观察而放宽工具权限。

保存为 probe.py:

"""Creates only fresh synthetic databases, records native SQLite observations."""
import json
from pathlib import Path
import platform
import sqlite3
import tempfile
from exact_report import tool_result
from readonly_tool import report_tool

CASES = {
    'empty': [],
    'zero': [('east', 0)],
    'missing': [('east', None)],
    'text': [('east', 'pending')],
    'numeric_prefix': [('east', '12units')],
    'fraction': [('east', 0.5)],
    'blob': [('east', b'12')],
    'invalid_zone': [(None, 1)],
    'negative': [('east', -1)],
    'numeric_text_affinity': [('east', '0007')],
    'large_exact': [('east', 9007199254740993)],
    'sum_overflow': [('east', 9223372036854775807), ('east', 1)],
    'late_bad_group': [('east', 2), ('west', 'pending')],
    'source_limit': [('east', 1)] * 1001,
    'group_limit': [('zone%02d' % n, 1) for n in range(21)],
}


def fixture(path, rows):
    if path.exists():
        raise FileExistsError(path)
    db = sqlite3.connect(path)
    try:
        db.execute('CREATE TABLE parcels(zone TEXT, units INTEGER, state TEXT, private_note TEXT)')
        db.executemany('INSERT INTO parcels VALUES (?, ?, ?, ?)',
                       [(zone, value, 'sent', 'synthetic-unused') for zone, value in rows])
        db.commit()
    finally:
        db.close()


def inspect_case(path):
    db = sqlite3.connect(path.as_uri() + '?mode=ro', uri=True)
    try:
        # An unguarded local observation connection, not the original public tool.
        storage = db.execute('SELECT typeof(units), COUNT(*) FROM parcels GROUP BY typeof(units) ORDER BY 1').fetchall()
        total = db.execute('SELECT TOTAL(units) FROM parcels').fetchone()[0]
    finally:
        db.close()
    try:
        native = {'status': 'ok', 'rows': report_tool(path, 'sent')['rows']}
    except sqlite3.DatabaseError as error:
        native = {'status': 'database_error', 'class': type(error).__name__, 'message': str(error)}
    return {'storage_classes': storage, 'original_report': native,
            'total_probe': total, 'exact_contract': tool_result(path, 'sent')}


def run():
    outcomes = {}
    with tempfile.TemporaryDirectory(prefix='synthetic-exact-units-') as folder:
        for name, rows in CASES.items():
            path = Path(folder) / (name + '.sqlite')
            fixture(path, rows)
            outcomes[name] = inspect_case(path)
    return {'python': platform.python_version(), 'sqlite': sqlite3.sqlite_version,
            'scope': 'local synthetic fixtures; no model, network or production database',
            'cases': outcomes}


if __name__ == '__main__':
    print(json.dumps(run(), ensure_ascii=False, indent=2))

fixtures.json 另存同一组输入的可读清单,测试会核对它与 CASES 一致;重复行使用次数描述,BLOB 使用十六进制标记。

保存为 fixtures.json:

{
  "empty": [],
  "zero": [
    [
      "east",
      0
    ]
  ],
  "missing": [
    [
      "east",
      null
    ]
  ],
  "text": [
    [
      "east",
      "pending"
    ]
  ],
  "numeric_prefix": [
    [
      "east",
      "12units"
    ]
  ],
  "fraction": [
    [
      "east",
      0.5
    ]
  ],
  "blob": [
    [
      "east",
      {
        "__bytes_hex__": "3132"
      }
    ]
  ],
  "invalid_zone": [
    [
      null,
      1
    ]
  ],
  "negative": [
    [
      "east",
      -1
    ]
  ],
  "numeric_text_affinity": [
    [
      "east",
      "0007"
    ]
  ],
  "large_exact": [
    [
      "east",
      9007199254740993
    ]
  ],
  "sum_overflow": [
    [
      "east",
      9223372036854775807
    ],
    [
      "east",
      1
    ]
  ],
  "late_bad_group": [
    [
      "east",
      2
    ],
    [
      "west",
      "pending"
    ]
  ],
  "source_limit": {
    "repeat": 1001,
    "row": [
      "east",
      1
    ]
  },
  "group_limit": [
    [
      "zone00",
      1
    ],
    [
      "zone01",
      1
    ],
    [
      "zone02",
      1
    ],
    [
      "zone03",
      1
    ],
    [
      "zone04",
      1
    ],
    [
      "zone05",
      1
    ],
    [
      "zone06",
      1
    ],
    [
      "zone07",
      1
    ],
    [
      "zone08",
      1
    ],
    [
      "zone09",
      1
    ],
    [
      "zone10",
      1
    ],
    [
      "zone11",
      1
    ],
    [
      "zone12",
      1
    ],
    [
      "zone13",
      1
    ],
    [
      "zone14",
      1
    ],
    [
      "zone15",
      1
    ],
    [
      "zone16",
      1
    ],
    [
      "zone17",
      1
    ],
    [
      "zone18",
      1
    ],
    [
      "zone19",
      1
    ],
    [
      "zone20",
      1
    ]
  ]
}

下面的 expected.json 是按合同逐项写定的预期结果,测试核对它与手写断言一致,变异检查再用它直接对照新增入口,不能用刚跑出的结果覆盖它。原查询的关键反例则由测试中的独立断言固定;本次完整观察值另存为 observed-exact.json,便于逐项对照。

保存为 expected.json:

{
  "empty": {
    "status": "ok",
    "rows": []
  },
  "zero": {
    "status": "ok",
    "rows": [
      {
        "zone": "east",
        "parcels": 1,
        "units_decimal": "0"
      }
    ]
  },
  "missing": {
    "status": "rejected",
    "reason": "missing_units"
  },
  "text": {
    "status": "rejected",
    "reason": "invalid_units_type"
  },
  "numeric_prefix": {
    "status": "rejected",
    "reason": "invalid_units_type"
  },
  "fraction": {
    "status": "rejected",
    "reason": "invalid_units_type"
  },
  "blob": {
    "status": "rejected",
    "reason": "invalid_units_type"
  },
  "invalid_zone": {
    "status": "rejected",
    "reason": "invalid_zone"
  },
  "negative": {
    "status": "rejected",
    "reason": "invalid_units_range"
  },
  "numeric_text_affinity": {
    "status": "ok",
    "rows": [
      {
        "zone": "east",
        "parcels": 1,
        "units_decimal": "7"
      }
    ]
  },
  "large_exact": {
    "status": "ok",
    "rows": [
      {
        "zone": "east",
        "parcels": 1,
        "units_decimal": "9007199254740993"
      }
    ]
  },
  "sum_overflow": {
    "status": "rejected",
    "reason": "sum_out_of_range"
  },
  "late_bad_group": {
    "status": "rejected",
    "reason": "invalid_units_type"
  },
  "source_limit": {
    "status": "rejected",
    "reason": "source_limit"
  },
  "group_limit": {
    "status": "rejected",
    "reason": "group_limit"
  }
}
展开本次原查询、TOTAL 探针与新增合同的完整观察输出

保存为 observed-exact.json:

{
  "python": "3.12.14",
  "sqlite": "3.53.1",
  "scope": "local synthetic fixtures; no model, network or production database",
  "cases": {
    "empty": {
      "storage_classes": [],
      "original_report": {
        "status": "ok",
        "rows": []
      },
      "total_probe": 0.0,
      "exact_contract": {
        "status": "ok",
        "rows": []
      }
    },
    "zero": {
      "storage_classes": [
        [
          "integer",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            0
          ]
        ]
      },
      "total_probe": 0.0,
      "exact_contract": {
        "status": "ok",
        "rows": [
          {
            "zone": "east",
            "parcels": 1,
            "units_decimal": "0"
          }
        ]
      }
    },
    "missing": {
      "storage_classes": [
        [
          "null",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            null
          ]
        ]
      },
      "total_probe": 0.0,
      "exact_contract": {
        "status": "rejected",
        "reason": "missing_units"
      }
    },
    "text": {
      "storage_classes": [
        [
          "text",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            0.0
          ]
        ]
      },
      "total_probe": 0.0,
      "exact_contract": {
        "status": "rejected",
        "reason": "invalid_units_type"
      }
    },
    "numeric_prefix": {
      "storage_classes": [
        [
          "text",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            12.0
          ]
        ]
      },
      "total_probe": 12.0,
      "exact_contract": {
        "status": "rejected",
        "reason": "invalid_units_type"
      }
    },
    "fraction": {
      "storage_classes": [
        [
          "real",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            0.5
          ]
        ]
      },
      "total_probe": 0.5,
      "exact_contract": {
        "status": "rejected",
        "reason": "invalid_units_type"
      }
    },
    "blob": {
      "storage_classes": [
        [
          "blob",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            12.0
          ]
        ]
      },
      "total_probe": 12.0,
      "exact_contract": {
        "status": "rejected",
        "reason": "invalid_units_type"
      }
    },
    "invalid_zone": {
      "storage_classes": [
        [
          "integer",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            null,
            1,
            1
          ]
        ]
      },
      "total_probe": 1.0,
      "exact_contract": {
        "status": "rejected",
        "reason": "invalid_zone"
      }
    },
    "negative": {
      "storage_classes": [
        [
          "integer",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            -1
          ]
        ]
      },
      "total_probe": -1.0,
      "exact_contract": {
        "status": "rejected",
        "reason": "invalid_units_range"
      }
    },
    "numeric_text_affinity": {
      "storage_classes": [
        [
          "integer",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            7
          ]
        ]
      },
      "total_probe": 7.0,
      "exact_contract": {
        "status": "ok",
        "rows": [
          {
            "zone": "east",
            "parcels": 1,
            "units_decimal": "7"
          }
        ]
      }
    },
    "large_exact": {
      "storage_classes": [
        [
          "integer",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            9007199254740993
          ]
        ]
      },
      "total_probe": 9007199254740992.0,
      "exact_contract": {
        "status": "ok",
        "rows": [
          {
            "zone": "east",
            "parcels": 1,
            "units_decimal": "9007199254740993"
          }
        ]
      }
    },
    "sum_overflow": {
      "storage_classes": [
        [
          "integer",
          2
        ]
      ],
      "original_report": {
        "status": "database_error",
        "class": "OperationalError",
        "message": "integer overflow"
      },
      "total_probe": 9.223372036854776e+18,
      "exact_contract": {
        "status": "rejected",
        "reason": "sum_out_of_range"
      }
    },
    "late_bad_group": {
      "storage_classes": [
        [
          "integer",
          1
        ],
        [
          "text",
          1
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1,
            2
          ],
          [
            "west",
            1,
            0.0
          ]
        ]
      },
      "total_probe": 2.0,
      "exact_contract": {
        "status": "rejected",
        "reason": "invalid_units_type"
      }
    },
    "source_limit": {
      "storage_classes": [
        [
          "integer",
          1001
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "east",
            1001,
            1001
          ]
        ]
      },
      "total_probe": 1001.0,
      "exact_contract": {
        "status": "rejected",
        "reason": "source_limit"
      }
    },
    "group_limit": {
      "storage_classes": [
        [
          "integer",
          21
        ]
      ],
      "original_report": {
        "status": "ok",
        "rows": [
          [
            "zone00",
            1,
            1
          ],
          [
            "zone01",
            1,
            1
          ],
          [
            "zone02",
            1,
            1
          ],
          [
            "zone03",
            1,
            1
          ],
          [
            "zone04",
            1,
            1
          ],
          [
            "zone05",
            1,
            1
          ],
          [
            "zone06",
            1,
            1
          ],
          [
            "zone07",
            1,
            1
          ],
          [
            "zone08",
            1,
            1
          ],
          [
            "zone09",
            1,
            1
          ]
        ]
      },
      "total_probe": 21.0,
      "exact_contract": {
        "status": "rejected",
        "reason": "group_limit"
      }
    }
  }
}

13. 验证拒绝后不会交付半份报告

测试同时检查数据合同和失败传播。east=2、west=pending 这一组先有可累计的值,整份响应仍只有 rejected 与 invalid_units_type。拒绝前后自建库主文件的 SHA-256 相同。恢复测试在单独的测试写连接中把 synthetic west 修正为整数 3,关闭后重新调用,才得到完整的两组结果:

{"status":"ok","rows":[{"zone":"east","parcels":1,"units_decimal":"2"},{"zone":"west","parcels":1,"units_decimal":"3"}]}

这次修正只为演示自有 fixture 的恢复步骤,不属于只读工具能力。真实业务中应由有权限的数据负责人核对并修正来源,再从一次新的完整调用开始,不能沿用上次计算了一半的小计。缺失数据库、schema 不符及查询中断须保留数据库执行失败,宿主可以把异常转换为明确的失败响应,不能让它落入空集成功分支。中断测试刻意把回调间隔设为 1 并立即中断,用来稳定检查 SQLITE_INTERRUPT 与连接清理;它没有测量正常阈值的耗时。

保存为 test_exact_report.py:

"""Hand-written business oracles, native local SQLite. No third-party dependencies."""
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
import exact_report
from probe import fixture, run


def ok(zone, count, units):
    return {'status': 'ok', 'rows': [
        {'zone': zone, 'parcels': count, 'units_decimal': units}]}


EXPECTED = {
    'empty': {'status': 'ok', 'rows': []},
    'zero': ok('east', 1, '0'),
    'missing': {'status': 'rejected', 'reason': 'missing_units'},
    'text': {'status': 'rejected', 'reason': 'invalid_units_type'},
    'numeric_prefix': {'status': 'rejected', 'reason': 'invalid_units_type'},
    'fraction': {'status': 'rejected', 'reason': 'invalid_units_type'},
    'blob': {'status': 'rejected', 'reason': 'invalid_units_type'},
    'invalid_zone': {'status': 'rejected', 'reason': 'invalid_zone'},
    'negative': {'status': 'rejected', 'reason': 'invalid_units_range'},
    'numeric_text_affinity': ok('east', 1, '7'),
    'large_exact': ok('east', 1, '9007199254740993'),
    'sum_overflow': {'status': 'rejected', 'reason': 'sum_out_of_range'},
    'late_bad_group': {'status': 'rejected', 'reason': 'invalid_units_type'},
    'source_limit': {'status': 'rejected', 'reason': 'source_limit'},
    'group_limit': {'status': 'rejected', 'reason': 'group_limit'},
}


class ExactReportTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.observed = run()['cases']

    def test_complete_hand_written_contract_oracles(self):
        self.assertEqual(set(self.observed), set(EXPECTED))
        for name, expected in EXPECTED.items():
            with self.subTest(name=name):
                self.assertEqual(self.observed[name]['exact_contract'], expected)

    def test_integer_affinity_does_not_reject_text_or_fraction(self):
        self.assertEqual(self.observed['text']['storage_classes'], [('text', 1)])
        self.assertEqual(self.observed['fraction']['storage_classes'], [('real', 1)])
        self.assertEqual(self.observed['numeric_text_affinity']['storage_classes'], [('integer', 1)])

    def test_native_counterexamples(self):
        self.assertEqual(self.observed['text']['original_report']['rows'], [('east', 1, 0.0)])
        self.assertEqual(self.observed['numeric_prefix']['original_report']['rows'], [('east', 1, 12.0)])
        self.assertEqual(self.observed['missing']['original_report']['rows'], [('east', 1, None)])

    def test_total_loses_exact_integer(self):
        seen = self.observed['large_exact']
        self.assertEqual(seen['original_report']['rows'], [('east', 1, 9007199254740993)])
        self.assertEqual(seen['total_probe'], 9007199254740992.0)
        self.assertNotEqual(int(seen['total_probe']), 9007199254740993)

    def test_native_sum_overflow_is_database_failure(self):
        self.assertEqual(self.observed['sum_overflow']['original_report']['status'], 'database_error')
        self.assertEqual(self.observed['sum_overflow']['original_report']['class'], 'OperationalError')

    def test_rejected_result_cannot_leak_partial_rows(self):
        for name, case in self.observed.items():
            result = case['exact_contract']
            if result['status'] == 'rejected':
                self.assertEqual(set(result), {'status', 'reason'}, name)

    def test_read_only_and_recovery_after_separate_synthetic_repair(self):
        with tempfile.TemporaryDirectory() as d:
            path = Path(d) / 'synthetic.sqlite'
            fixture(path, [('east', 2), ('west', 'pending')])
            before = hashlib.sha256(path.read_bytes()).hexdigest()
            self.assertEqual(exact_report.tool_result(path, 'sent'), EXPECTED['late_bad_group'])
            self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), before)
            writer = sqlite3.connect(path)
            try:
                writer.execute('UPDATE parcels SET units = ? WHERE zone = ?', (3, 'west'))
                writer.commit()
            finally:
                writer.close()
            # A fresh invocation starts from scratch, never joins a prior partial result.
            self.assertEqual(exact_report.tool_result(path, 'sent'), {'status': 'ok', 'rows': [
                {'zone': 'east', 'parcels': 1, 'units_decimal': '2'},
                {'zone': 'west', 'parcels': 1, 'units_decimal': '3'}]})

    def test_readonly_open_failure_propagates_and_creates_no_file(self):
        with tempfile.TemporaryDirectory() as d:
            path = Path(d) / 'missing.sqlite'
            with self.assertRaises(sqlite3.DatabaseError):
                exact_report.tool_result(path, 'sent')
            self.assertFalse(path.exists())

    def test_source_limit_exact_boundary(self):
        with tempfile.TemporaryDirectory() as d:
            path = Path(d) / 'synthetic.sqlite'
            fixture(path, [('east', 1)] * 1000)
            self.assertEqual(exact_report.tool_result(path, 'sent'), ok('east', 1000, '1000'))

    def test_signed_64_bit_total_exact_boundary(self):
        with tempfile.TemporaryDirectory() as d:
            path = Path(d) / 'synthetic.sqlite'
            fixture(path, [('east', 9223372036854775806), ('east', 1)])
            self.assertEqual(exact_report.tool_result(path, 'sent'), ok('east', 2, '9223372036854775807'))

    def test_state_filter_and_invalid_options(self):
        with tempfile.TemporaryDirectory() as d:
            path = Path(d) / 'synthetic.sqlite'
            fixture(path, [('east', 1)])
            self.assertEqual(exact_report.tool_result(path, 'queued'), {'status': 'ok', 'rows': []})
            for state in (True, None, [], 'all', "sent' OR 1=1 --"):
                self.assertEqual(exact_report.tool_result(path, state),
                                 {'status': 'rejected', 'reason': 'invalid_state'})

    def test_closes_connection_on_rejection(self):
        with tempfile.TemporaryDirectory() as d:
            path = Path(d) / 'synthetic.sqlite'
            fixture(path, [('east', None)])
            real = exact_report.connection(path, guarded=True)
            closed = []
            class Spy:
                def __getattr__(self, name):
                    return getattr(real, name)
                def close(self):
                    real.close()
                    closed.append(True)
            with patch.object(exact_report, 'connection', return_value=Spy()):
                self.assertEqual(exact_report.tool_result(path, 'sent'), EXPECTED['missing'])
            self.assertEqual(closed, [True])
            with self.assertRaises(sqlite3.ProgrammingError):
                real.execute('SELECT 1')

    def test_dependency_matches_cleanup_revision(self):
        root = Path(__file__).parent
        # This is artifact identity, not an oracle of correct program behavior.
        expected = '69d39925eb1f6ab183a63cf333516663bef36f6ba93bda555127eac9e8132873'
        self.assertEqual(hashlib.sha256((root / 'readonly_tool.py').read_bytes()).hexdigest(), expected)

    def test_standalone_expected_file_matches_handwritten_oracle(self):
        root = Path(__file__).parent
        self.assertEqual(json.loads((root / 'expected.json').read_text()), EXPECTED)

    def test_standalone_fixture_manifest_matches_inputs(self):
        from probe import CASES
        root = Path(__file__).parent
        def decode(value):
            if isinstance(value, dict) and set(value) == {'__bytes_hex__'}:
                return bytes.fromhex(value['__bytes_hex__'])
            if isinstance(value, dict) and set(value) == {'repeat', 'row'}:
                if type(value['repeat']) is not int or not 0 <= value['repeat'] <= 1001:
                    raise ValueError('invalid synthetic repeat count')
                if not isinstance(value['row'], list) or len(value['row']) != 2:
                    raise ValueError('invalid synthetic repeated row')
                return [value['row'].copy() for _ in range(value['repeat'])]
            return value
        serialized = json.loads((root / 'fixtures.json').read_text(), object_hook=decode)
        self.assertEqual({name: [tuple(row) for row in rows] for name, rows in serialized.items()}, CASES)

    def test_twenty_groups_is_complete_success(self):
        with tempfile.TemporaryDirectory() as d:
            path = Path(d) / 'synthetic.sqlite'
            fixture(path, [('zone%02d' % n, 1) for n in range(20)])
            expected = {'status': 'ok', 'rows': [
                {'zone': 'zone%02d' % n, 'parcels': 1, 'units_decimal': '1'}
                for n in range(20)]}
            self.assertEqual(exact_report.tool_result(path, 'sent'), expected)

    def test_empty_and_overlong_zone_rejected(self):
        with tempfile.TemporaryDirectory() as d:
            for i, zone in enumerate(('', 'x' * 81)):
                path = Path(d) / ('zone%d.sqlite' % i)
                fixture(path, [(zone, 1)])
                self.assertEqual(exact_report.tool_result(path, 'sent'), EXPECTED['invalid_zone'])

    def test_missing_schema_failure_is_not_empty_success(self):
        with tempfile.TemporaryDirectory() as d:
            path = Path(d) / 'synthetic.sqlite'
            db = sqlite3.connect(path)
            db.execute('CREATE TABLE unrelated(x)')
            db.close()
            with self.assertRaises(sqlite3.DatabaseError):
                exact_report.tool_result(path, 'sent')

    def test_progress_interrupt_propagates_and_closes(self):
        with tempfile.TemporaryDirectory() as d:
            path = Path(d) / 'synthetic.sqlite'
            fixture(path, [('east', 1)] * 200)
            real = exact_report.connection(path, guarded=True)
            closed = []
            class InterruptImmediately:
                def __getattr__(self, name):
                    return getattr(real, name)
                def set_progress_handler(self, callback, interval):
                    # Deterministic injection, not a performance/budget measurement.
                    real.set_progress_handler(lambda: 1, 1)
                def close(self):
                    real.close()
                    closed.append(True)
            with patch.object(exact_report, 'connection', return_value=InterruptImmediately()):
                with self.assertRaises(sqlite3.OperationalError) as caught:
                    exact_report.tool_result(path, 'sent')
            self.assertEqual(caught.exception.sqlite_errorcode, sqlite3.SQLITE_INTERRUPT)
            self.assertEqual(closed, [True])


if __name__ == '__main__':
    unittest.main(verbosity=2)

补充的 3 个测试方法覆盖 15 个查询主体与关闭组合:progress 注册、execute、fetchall、业务拒绝及成功结果,每个分别搭配正常关闭、RuntimeError 和 SystemExit。测试使用自有临时库与代理注入,检查原异常对象、只关闭一次、拒绝无部分结果,以及成功路径关闭失败继续传播。修正前 15 个组合中 8 个不满足这些断言,修正后全部通过;原 23 项、19 个整数报表方法、5 个初始化清理方法及 8 个变异检查仍通过。这不是原有初始化检查的重复,也不证明生产 SQLite 会自然出现这些关闭故障。

保存为 test_exact_cleanup.py:

"""Synthetic exact-report body/cleanup failures; no real user database."""
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import exact_report
from fixture import create_fixture

class ExactCleanupTests(unittest.TestCase):
    def exercise(self, stages):
        results=[]
        with tempfile.TemporaryDirectory(prefix='owned-') as folder:
         p=Path(folder)/'synthetic.sqlite';create_fixture(p)
         for stage in stages:
          for close_type in [None,RuntimeError,SystemExit]:
           db=exact_report.connection(p,guarded=True)
           original=KeyboardInterrupt('injected '+stage)
           cleanup=close_type('injected cleanup') if close_type else None
           calls=[]
           class Proxy:
            def set_progress_handler(self,*args):
             if stage=='progress':raise original
             return db.set_progress_handler(*args)
            def execute(self,*args):
             if stage=='execute':raise original
             if stage=='fetchall':
              class Cursor:
               def fetchall(self):raise original
              return Cursor()
             if stage=='validation':
              class Cursor:
               def fetchall(self):return [('east',None)]
              return Cursor()
             return db.execute(*args)
            def close(self):
             calls.append('close')
             if cleanup:raise cleanup
             db.close()
           try:
            with patch.object(exact_report,'connection',return_value=Proxy()):
             try:result=exact_report.tool_result(p,'sent');caught=None
             except BaseException as e:caught=e;result=None
            if stage in ['progress','execute','fetchall']:passed=caught is original
            elif stage=='validation':passed=caught is None and result=={'status':'rejected','reason':'missing_units'}
            elif cleanup:passed=caught is cleanup
            else:passed=caught is None and result=={'status':'ok','rows':[{'zone':'east','parcels':1,'units_decimal':'2'},{'zone':'west','parcels':1,'units_decimal':'3'}]}
            closed=False
            try:db.execute('SELECT 1')
            except sqlite3.ProgrammingError:closed=True
            passed=passed and len(calls)==1 and (cleanup is not None or closed)
            results.append({'stage':stage,'close_error':close_type.__name__ if close_type else None,'pass':passed,'raised':type(caught).__name__ if caught else None,'returned':result,'close_attempts':len(calls),'handle_closed':closed})
           finally:db.close()
        self.assertEqual(len(results),len(stages)*3)
        self.assertTrue(all(r['pass'] for r in results),results)

    def test_primary_body_failure_preserved(self):
        self.exercise(['progress', 'execute', 'fetchall'])

    def test_validation_rejection_preserved(self):
        self.exercise(['validation'])

    def test_success_close_failure_propagates(self):
        self.exercise(['success'])

if __name__ == '__main__':
    unittest.main(verbosity=2)

运行主体失败与关闭组合检查:

python -B -m unittest -v test_exact_cleanup.py

14. 把错误改法放回去,检查测试能否拦住

只检查正确实现会通过,容易遗漏测试自身的问题。下面的脚本在独立命名空间中编译每个错误版本,只把该版本对应的合成库放进临时目录;磁盘上的原实现不变。每次只改一处,实际调用后与 expected.json 的固定结果比较。错误版本若仍返回预期值,脚本会报 mutant survived;语法错误或意外异常也会使脚本失败,不能被计为成功检出。输出列出错误结果和预期结果。

保存为 check_mutants.py:

"""Deliberately wrong in-memory variants must disagree with fixed expected results.

This script never modifies exact_report.py or connects to external databases.
"""
import json
from pathlib import Path
import tempfile
from probe import CASES, fixture

ROOT = Path(__file__).parent
SOURCE = (ROOT / 'exact_report.py').read_text()
EXPECTED = json.loads((ROOT / 'expected.json').read_text())
MUTANTS = [
    ('null_as_zero', "raise ReportRejected('missing_units')", 'units = 0', 'missing'),
    ('coerce_float', "raise ReportRejected('invalid_units_type')", 'units = int(units)', 'fraction'),
    ('truncate_source', "raise ReportRejected('source_limit')", 'rows = rows[:SOURCE_LIMIT]', 'source_limit'),
    ('ignore_group_limit', "raise ReportRejected('group_limit')", 'pass', 'group_limit'),
    ('ignore_total_range', "raise ReportRejected('sum_out_of_range')", 'pass', 'sum_overflow'),
    ('float_total', "'units_decimal': str(total)", "'units_decimal': str(int(float(total)))", 'large_exact'),
    ('rejection_as_empty', "return {'status': 'rejected', 'reason': str(error)}",
        "return {'status': 'ok', 'rows': []}", 'late_bad_group'),
    ('allow_negative', 'if not 0 <= units <= MAX_I64:', 'if not -1 <= units <= MAX_I64:', 'negative'),
]


def main():
    results = []
    with tempfile.TemporaryDirectory(prefix='synthetic-mutants-') as folder:
        for name, old, new, case in MUTANTS:
            if SOURCE.count(old) != 1:
                raise AssertionError('mutation anchor mismatch: ' + name)
            namespace = {'__name__': 'synthetic_' + name}
            exec(compile(SOURCE.replace(old, new), name + '.py', 'exec'), namespace)
            path = Path(folder) / (name + '.sqlite')
            fixture(path, CASES[case])
            actual = namespace['tool_result'](path, 'sent')
            if actual == EXPECTED[case]:
                raise AssertionError('mutant survived: ' + name)
            results.append({'mutant': name, 'fixture': case, 'caught': True,
                            'wrong_result': actual, 'expected': EXPECTED[case]})
    print(json.dumps({'scope': 'local in-memory deliberate bugs', 'mutants': results}, indent=2))


if __name__ == '__main__':
    main()

本次本地复跑的新增测试为 19 个 unittest 测试方法,全部通过,其中逐项核对 15 组输入。8 个故意错误的版本全部产生与固定预期不同的结果。另保留原实验的 23 项检查;这些数字分别描述各自的检查集合,不代表生产覆盖率。

TOTAL 探针本身也是一个负面对照:不产生整数溢出异常,仍会让 9007199254740993 丢掉最后一位。验收时要固定期待的整数或拒绝状态,不能只断言查询没有报错。把超量输入截为前 1000 行、把 NULL 当零、把总量转 float,以及取消总量业务上限,都会与相应固定预期不符。

15. 收到拒绝或执行失败后怎样继续

  • missing_units、invalid_units_type、invalid_units_range:核对导入来源、缺失标记与单位定义。保留失败原因,别把坏行丢掉后仍称为完整统计。
  • invalid_zone:检查地区字段的缺失、类型及长度。当前合同不做清理、合并或别名归一化,来源修正后再重新生成。
  • sum_out_of_range:先检查单位是否用错、是否重复导入,或业务是否真的需要更大范围。若要扩大范围,应连同接收端字段合同和边界测试一起修改。
  • source_limit、group_limit:本次完整报告没有形成。重新明确筛选范围,或设计经验证的全量处理方式;只提高数字不能自动解决扫描耗时与内存开销。
  • DatabaseError:调查文件路径、schema、权限和中断原因。保留执行失败,关闭连接;问题解决后重新读取整个目标集合。

补充实验只覆盖这里的合成普通表、默认类型映射与有界整数统计。没有验证生产数据库、恶意文件、虚拟表、自定义 converter、并发写入、多版本兼容、真实模型调用、总响应字节上限或硬 deadline。既有只读边界和身份授权要求继续适用。SVG 已做本地图像检查,HTML 做结构与源码提取校验,未做浏览器像素验收。

本节关注数据已经存入 SQLite 之后,哪些值可以参与精确汇总及怎样交付完整结果。备份时点与跨页一致性仍分别需要备份、事务和分页实验来验证;这里没有新增这些结论。

可以把模型生成的任意 SQL 传进这个工具吗?

本例仅开放 state 和 limit。SQL、路径及允许对象由可信宿主固定,没有通用 SQL 参数。

mode=ro 是否会隐藏内部备注?

不会。本实验能读取合成 private_note;表列权限由额外的 authorizer 规则限制,业务用户授权还需独立实现。

progress handler 能确保查询在指定毫秒内结束吗?

不能。回调依据近似 VM 指令推进,本文的约 30ms 函数在零次回调时结束。硬墙钟限制需另行设计。

COUNT(*) 被拒绝时能允许所有 database=None 吗?

不应如此放宽。先观察目标版本与具体 SQL 的回调参数,保持失败关闭;显式列计数还需检查 NULL 语义。