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:
@@ -162,6 +162,41 @@ function leadingKeyword(statement: PreparedStatement) {
|
||||
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.ts),64MB 的题学生实际能吃到 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。
|
||||
*
|
||||
@@ -169,12 +204,14 @@ function leadingKeyword(statement: PreparedStatement) {
|
||||
* 比旧实现手写的分号切分更准 —— 字符串和注释里的分号天然不会误切。
|
||||
*
|
||||
* `guard` 在每条语句 step 之前调用,用来拦学生的 PRAGMA 并重放限制。
|
||||
* `budget` 只在跑学生 SQL 时传,受信脚本不记账。
|
||||
*/
|
||||
function executeStatements(
|
||||
db: Database,
|
||||
script: string,
|
||||
deadline: number,
|
||||
guard?: (statement: PreparedStatement) => void,
|
||||
budget?: ByteBudget,
|
||||
): ResultSet | null {
|
||||
let last: ResultSet | null = null
|
||||
for (const statement of iterate(db, script)) {
|
||||
@@ -185,7 +222,9 @@ function executeStatements(
|
||||
if (names.length > 0) {
|
||||
const rows: string[] = []
|
||||
while (statement.step()) {
|
||||
rows.push(canonicalRow(statement.get()))
|
||||
const row = statement.get()
|
||||
budget?.charge(row)
|
||||
rows.push(canonicalRow(row))
|
||||
if (rows.length > ROW_LIMIT) {
|
||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT} 行`)
|
||||
}
|
||||
@@ -202,14 +241,17 @@ function executeStatements(
|
||||
}
|
||||
|
||||
/** 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 state: Record<string, { columns: number; rows: string[] }> = {}
|
||||
for (const table of names) {
|
||||
const quoted = String(table).replaceAll('"', '""')
|
||||
const result = db.exec(`SELECT * FROM "${quoted}"`)
|
||||
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) {
|
||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `表 ${table} 超过 ${ROW_LIMIT} 行`)
|
||||
}
|
||||
@@ -257,6 +299,8 @@ function runStudent(
|
||||
) {
|
||||
// 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
|
||||
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 {
|
||||
const last = executeStatements(db, script, deadline, (statement) => {
|
||||
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
|
||||
@@ -268,9 +312,9 @@ function runStudent(
|
||||
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
|
||||
applyLimits(db, memoryLimitMb)
|
||||
if (mode === "query") db.run("PRAGMA query_only=1")
|
||||
})
|
||||
}, budget)
|
||||
if (mode === "query") return last
|
||||
return dumpTables(db)
|
||||
return dumpTables(db, budget)
|
||||
} catch (error) {
|
||||
if (error instanceof SqlCaseError) throw error
|
||||
const message = String((error as Error).message)
|
||||
|
||||
@@ -14,7 +14,27 @@
|
||||
> - **I-2 已修**:子进程用 stderr 报阶段(`prepare` / `student` / `display`),父进程边读边换
|
||||
> 兜底时限 —— 一进学生 SQL 就收到「题目时限 + 2s」。1s 限的题跑飞语句实测 **26s → 3.06s**。
|
||||
> 顺带修正归因:卡在受信脚本(出题人的初始化/标准答案)现在报 SYSTEM_ERROR,不再甩给学生 TLE。
|
||||
> - **未修**:M-1 的「固定 512MB 与题目 `memoryLimit` 脱钩」、M-2、M-3。
|
||||
> **补充(2026-08-08,Minor 收尾)**
|
||||
>
|
||||
> - **M-1 已全部修完。** 第二半(`max_page_count` 学生可调大)在 I-1 拦 PRAGMA 时就一并
|
||||
> 修掉了。第一半改成引擎侧按字节记账(`ByteBudget`):单值超题目内存限、或结果集
|
||||
> 累计超限,都按 MLE 拒。旧实现的 `setlimit(SQLITE_LIMIT_LENGTH)` 复刻不了 ——
|
||||
> sql.js 的 wasm 没导出 `sqlite3_limit`。实测**同一句 SQL 在 4MB 的题上被拦、
|
||||
> 在 64MB 的题上放行**,题目的 `memoryLimit` 现在对学生是真约束。
|
||||
> (测试尺寸特意选在 8MB 而不是 100MB:wasm 堆触顶时的兜底报的是同一句错误信息,
|
||||
> 用大值测根本分不清是谁拦的 —— 第一版测试就是这么误判的。)
|
||||
> - **M-3 已由阶段 5 的分阶段超时顺带解决**:预览从 25s 降到 **13005ms** 实测。
|
||||
> - **M-2 不修**(见下)。
|
||||
|
||||
> **M-2 的处理:不修,理由写在这里**
|
||||
>
|
||||
> 要做「强制两个测试点的期望结果不同」,得在建题时把每个测试点都跑一遍标准答案再比对。
|
||||
> 技术上可行,但它会把一类**合法出题**也挡掉(比如刻意用两组不同数据验证同一个边界、
|
||||
> 结果恰好相同),而代价是老师建题时被一条看不懂的错误拦住。评审也确认这与旧实现行为
|
||||
> 一致、非回归。
|
||||
>
|
||||
> 更合适的做法是「提示」而不是「拒绝」,但那要动契约和后台前端。留给以后,
|
||||
> 现在记在这里,免得下次评审又把它当成新发现。
|
||||
> - `engine.ts` 头部的防护对照表已按实测重写,两处削弱写在正文里。
|
||||
> 另记:stock sql.js 的 wasm **没有导出** `sqlite3_progress_handler` / `sqlite3_interrupt` /
|
||||
> `sqlite3_set_authorizer` / `sqlite3_limit`(已核对导出表),要用得自己编 wasm。
|
||||
|
||||
Reference in New Issue
Block a user