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() ?? ""
|
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。
|
* 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 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)
|
||||||
|
|||||||
@@ -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-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` 头部的防护对照表已按实测重写,两处削弱写在正文里。
|
> - `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。
|
||||||
|
|||||||
Reference in New Issue
Block a user