fix(SQL 沙箱 Minor): 让题目的 memoryLimit 对学生真正生效

## M-1

题目写 64MB 也没意义 —— 唯一的硬顶是子进程那个固定 512MB 的 ulimit -d,
学生实际能吃到 8 倍。旧实现是 setlimit(SQLITE_LIMIT_LENGTH, memoryLimit)
把单值长度贴着题目内存限,但 sql.js 的 wasm 没导出 sqlite3_limit(已核对导出表),
复刻不了。

改成引擎侧按字节记账(ByteBudget):取行时累加,单值超限或结果集累计超限都按 MLE 拒。
max_page_count 管的是库文件页数,管不住「一个 SELECT 拼出一个巨大的值」,所以两者
不重复。查询题和增删改题(dumpTables)两条路都记账,受信脚本不记。

实测 6 条,关键是**同一句 SQL 在 4MB 的题上被拦、在 64MB 的题上放行**,
证明限制跟着题目走而不是一刀切。

第一版测试用的是 100MB 的值,结论是错的:wasm 堆触顶时的兜底和我的记账器报的是
同一句「单个数据值超出内存限制」,大值根本分不清是谁拦的。换成 8MB 才测得准。
这个教训写进注释了。

M-1 的第二半(max_page_count 学生可自行调大)在 I-1 拦 PRAGMA 时已一并修掉。

## M-3

阶段 5 的分阶段兜底超时顺带解决:出题人预览死循环从 25s 降到 13005ms 实测。

## M-2 不修

「强制两个测试点期望结果不同」会把合法出题也挡掉(刻意用两组数据验证同一边界、
结果恰好相同),代价是老师被一条看不懂的错误拦住。评审也确认与旧实现一致、非回归。
更合适的是提示而非拒绝,但要动契约和后台前端。理由写进评审文档,免得下次又当新发现。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 02:27:35 -06:00
parent cafa92a102
commit c59ecd6dbb
2 changed files with 70 additions and 6 deletions

View File

@@ -162,6 +162,41 @@ function leadingKeyword(statement: PreparedStatement) {
return text.trimStart().split(/[\s(;]/, 1)[0]?.toUpperCase() ?? "" return text.trimStart().split(/[\s(;]/, 1)[0]?.toUpperCase() ?? ""
} }
/**
* 让题目的 `memoryLimit` 对学生真正生效的记账器。
*
* 旧实现用 `setlimit(SQLITE_LIMIT_LENGTH, memoryLimit)` 把单值长度贴着题目内存限。
* sql.js 的 wasm **没导出** `sqlite3_limit`(已核对导出表),复刻不了,于是改成
* 取行时按字节记账:单值超限、或整个结果集累计超限,都按 MLE 拒掉。
*
* 不这么做的话题目写 64MB 也没意义:唯一的硬顶是子进程那个固定 512MB 的
* `ulimit -d`(见 index.ts64MB 的题学生实际能吃到 8 倍。
* `max_page_count` 管的是库文件页数,管不住「一个 SELECT 拼出一个巨大的值」。
*/
class ByteBudget {
private used = 0
constructor(private readonly maxBytes: number) {}
charge(row: unknown[]) {
for (const value of row) {
const bytes =
value instanceof Uint8Array
? value.byteLength
: typeof value === "string"
? Buffer.byteLength(value)
: 8 // 数字和 NULL 按定长算,撑不出内存
if (bytes > this.maxBytes) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
}
this.used += bytes
if (this.used > this.maxBytes) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "查询结果超出内存限制")
}
}
}
}
/** /**
* 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 null。 * 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 null。
* *
@@ -169,12 +204,14 @@ function leadingKeyword(statement: PreparedStatement) {
* 比旧实现手写的分号切分更准 —— 字符串和注释里的分号天然不会误切。 * 比旧实现手写的分号切分更准 —— 字符串和注释里的分号天然不会误切。
* *
* `guard` 在每条语句 step 之前调用,用来拦学生的 PRAGMA 并重放限制。 * `guard` 在每条语句 step 之前调用,用来拦学生的 PRAGMA 并重放限制。
* `budget` 只在跑学生 SQL 时传,受信脚本不记账。
*/ */
function executeStatements( function executeStatements(
db: Database, db: Database,
script: string, script: string,
deadline: number, deadline: number,
guard?: (statement: PreparedStatement) => void, guard?: (statement: PreparedStatement) => void,
budget?: ByteBudget,
): ResultSet | null { ): ResultSet | null {
let last: ResultSet | null = null let last: ResultSet | null = null
for (const statement of iterate(db, script)) { for (const statement of iterate(db, script)) {
@@ -185,7 +222,9 @@ function executeStatements(
if (names.length > 0) { if (names.length > 0) {
const rows: string[] = [] const rows: string[] = []
while (statement.step()) { while (statement.step()) {
rows.push(canonicalRow(statement.get())) const row = statement.get()
budget?.charge(row)
rows.push(canonicalRow(row))
if (rows.length > ROW_LIMIT) { if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT}`) throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT}`)
} }
@@ -202,14 +241,17 @@ function executeStatements(
} }
/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */ /** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */
function dumpTables(db: Database) { function dumpTables(db: Database, budget?: ByteBudget) {
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
const state: Record<string, { columns: number; rows: string[] }> = {} const state: Record<string, { columns: number; rows: string[] }> = {}
for (const table of names) { for (const table of names) {
const quoted = String(table).replaceAll('"', '""') const quoted = String(table).replaceAll('"', '""')
const result = db.exec(`SELECT * FROM "${quoted}"`) const result = db.exec(`SELECT * FROM "${quoted}"`)
const first = result[0] const first = result[0]
const rows = (first?.values ?? []).map((row) => canonicalRow(row as unknown[])) const rows = (first?.values ?? []).map((row) => {
budget?.charge(row as unknown[])
return canonicalRow(row as unknown[])
})
if (rows.length > ROW_LIMIT) { if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `${table} 超过 ${ROW_LIMIT}`) throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `${table} 超过 ${ROW_LIMIT}`)
} }
@@ -257,6 +299,8 @@ function runStudent(
) { ) {
// 查询题只读PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单 // 查询题只读PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
if (mode === "query") db.run("PRAGMA query_only=1") if (mode === "query") db.run("PRAGMA query_only=1")
// 把题目的 memoryLimit 变成学生看得见的约束,替代旧实现的 setlimit(LIMIT_LENGTH)
const budget = new ByteBudget(Math.max(Math.trunc(memoryLimitMb), 1) * 1024 * 1024)
try { try {
const last = executeStatements(db, script, deadline, (statement) => { const last = executeStatements(db, script, deadline, (statement) => {
// query_only 自己就是个 PRAGMA不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0` // query_only 自己就是个 PRAGMA不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
@@ -268,9 +312,9 @@ function runStudent(
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍 // 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
applyLimits(db, memoryLimitMb) applyLimits(db, memoryLimitMb)
if (mode === "query") db.run("PRAGMA query_only=1") if (mode === "query") db.run("PRAGMA query_only=1")
}) }, budget)
if (mode === "query") return last if (mode === "query") return last
return dumpTables(db) return dumpTables(db, budget)
} catch (error) { } catch (error) {
if (error instanceof SqlCaseError) throw error if (error instanceof SqlCaseError) throw error
const message = String((error as Error).message) const message = String((error as Error).message)

View File

@@ -14,7 +14,27 @@
> - **I-2 已修**:子进程用 stderr 报阶段(`prepare` / `student` / `display`),父进程边读边换 > - **I-2 已修**:子进程用 stderr 报阶段(`prepare` / `student` / `display`),父进程边读边换
> 兜底时限 —— 一进学生 SQL 就收到「题目时限 + 2s」。1s 限的题跑飞语句实测 **26s → 3.06s**。 > 兜底时限 —— 一进学生 SQL 就收到「题目时限 + 2s」。1s 限的题跑飞语句实测 **26s → 3.06s**。
> 顺带修正归因:卡在受信脚本(出题人的初始化/标准答案)现在报 SYSTEM_ERROR不再甩给学生 TLE。 > 顺带修正归因:卡在受信脚本(出题人的初始化/标准答案)现在报 SYSTEM_ERROR不再甩给学生 TLE。
> - **未修**M-1 的「固定 512MB 与题目 `memoryLimit` 脱钩」、M-2、M-3。 > **补充2026-08-08Minor 收尾)**
>
> - **M-1 已全部修完。** 第二半(`max_page_count` 学生可调大)在 I-1 拦 PRAGMA 时就一并
> 修掉了。第一半改成引擎侧按字节记账(`ByteBudget`):单值超题目内存限、或结果集
> 累计超限,都按 MLE 拒。旧实现的 `setlimit(SQLITE_LIMIT_LENGTH)` 复刻不了 ——
> sql.js 的 wasm 没导出 `sqlite3_limit`。实测**同一句 SQL 在 4MB 的题上被拦、
> 在 64MB 的题上放行**,题目的 `memoryLimit` 现在对学生是真约束。
> (测试尺寸特意选在 8MB 而不是 100MBwasm 堆触顶时的兜底报的是同一句错误信息,
> 用大值测根本分不清是谁拦的 —— 第一版测试就是这么误判的。)
> - **M-3 已由阶段 5 的分阶段超时顺带解决**:预览从 25s 降到 **13005ms** 实测。
> - **M-2 不修**(见下)。
> **M-2 的处理:不修,理由写在这里**
>
> 要做「强制两个测试点的期望结果不同」,得在建题时把每个测试点都跑一遍标准答案再比对。
> 技术上可行,但它会把一类**合法出题**也挡掉(比如刻意用两组不同数据验证同一个边界、
> 结果恰好相同),而代价是老师建题时被一条看不懂的错误拦住。评审也确认这与旧实现行为
> 一致、非回归。
>
> 更合适的做法是「提示」而不是「拒绝」,但那要动契约和后台前端。留给以后,
> 现在记在这里,免得下次评审又把它当成新发现。
> - `engine.ts` 头部的防护对照表已按实测重写,两处削弱写在正文里。 > - `engine.ts` 头部的防护对照表已按实测重写,两处削弱写在正文里。
> 另记stock sql.js 的 wasm **没有导出** `sqlite3_progress_handler` / `sqlite3_interrupt` / > 另记stock sql.js 的 wasm **没有导出** `sqlite3_progress_handler` / `sqlite3_interrupt` /
> `sqlite3_set_authorizer` / `sqlite3_limit`(已核对导出表),要用得自己编 wasm。 > `sqlite3_set_authorizer` / `sqlite3_limit`(已核对导出表),要用得自己编 wasm。