## 权限边界评审(后台 86 个 handler) 守卫本身一个没漏,问题全在对象级归属校验: - C1 跨题单删奖章:先按 (id, problemsetId) 校验奖章归属再删, 否则拿自己的题单 id + 别人的奖章 id 就能把别人的 user_badge 删掉 - C2 make-public 无归属校验:补 canEdit,越权者拿不到题面 - I1 两个分析端点被 `/problems/:id` 遮蔽 —— Hono 按注册顺序匹配, 不是静态优先。挪到 `/problem-analytics/*` - I2 from-public 只校验目标比赛归属:源题也必须是公开题库题 - I3 克隆比赛回传原比赛明文密码:克隆一律 password: null - I4 upload-image 守卫比旧后端严,教师写题面会 403:收回 requireAdmin 两个互不可见的教师账号实跑复验,六条全部拦住。 ## SQL 判题沙箱评审 - I-1 查询题只读被一句 `PRAGMA query_only=0` 关掉,实测 DML 拿到 AC。 query_only 自己就是个 PRAGMA,旧实现靠 authorizer 把 SQLITE_PRAGMA 一律拒了才没这个洞。现在 runStudent 逐语句拦 PRAGMA(用 sqlite3_normalized_sql 判关键字,注释和大小写由 SQLite 抹平), 并在每条语句前重放 query_only 和 max_page_count 兜底。 顺带修掉 M-1 里 max_page_count 学生可自行调大的部分。 - I-2 单条语句进了 step() 就打断不了,只能等父进程 SIGKILL, 而兜底时限是整个作业一口价 25s —— 1s 限的题要 26s 才判 TLE, 判题池只有 2 个槽,几发死循环就能把所有人堵住。 改成分阶段:子进程用 stderr 报 prepare/student/display, 父进程边读边换表,一进学生 SQL 就把兜底收到「题目时限 + 2s」。 实测 26s → 3.06s。 归因也跟着修了:卡在受信脚本(出题人的初始化脚本、标准答案) 现在报 SYSTEM_ERROR,不再当成学生超时甩 TLE。 engine.ts 头部那张防护对照表按实测重写 —— 原来那版把 query_only 写成等价于 authorizer 白名单,是不成立的。另记一笔:stock sql.js 的 wasm 没导出 progress_handler / interrupt / set_authorizer / limit, 想要得自己编,别再去翻了。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
553 lines
21 KiB
TypeScript
553 lines
21 KiB
TypeScript
/**
|
||
* SQL 题判题核心:在 WASM SQLite 里分别执行标准答案和学生 SQL 并比对结果。
|
||
* 移植自旧后端 `judge/sql_runner.py`,判定口径逐条对齐。
|
||
*
|
||
* 查询题(mode="query")比对最后一条 SELECT 的结果集;
|
||
* 增删改题(mode="modify")比对执行后所有用户表的最终状态。
|
||
*
|
||
* ## 防护为什么和旧实现不一样
|
||
*
|
||
* 旧实现用 Python sqlite3 的 set_authorizer / set_progress_handler / setlimit 三件套。
|
||
* bun:sqlite 一个都没有,且实测 Worker.terminate() 杀不掉跑飞的查询(原生代码占着线程)。
|
||
* 所以改成「WASM 引擎 + 独立子进程」,逐条替代:
|
||
*
|
||
* | 旧防护 | 新做法 |
|
||
* |---|---|
|
||
* | authorizer 禁 ATTACH(防读写服务器任意 SQLite 文件) | WASM 没有宿主文件系统绑定,ATTACH **结构上**够不到宿主,只能碰随进程消失的虚拟 FS |
|
||
* | authorizer 白名单让查询题只读 | `PRAGMA query_only=1` **加上逐语句拒绝学生的 PRAGMA**,见 runStudent |
|
||
* | progress_handler 墙钟超时 | 语句**之间**查 deadline + 子进程外部 SIGKILL 兜底,见下 |
|
||
* | setlimit(LIMIT_LENGTH) 防单值撑爆内存 | 子进程 `ulimit -d`,触顶时 WASM 抛可捕获错误 |
|
||
* | max_page_count | 保留,且每条学生语句前重放一遍(否则学生能自己调大) |
|
||
*
|
||
* 两处必须知道的削弱:
|
||
*
|
||
* 1. **只有 query_only 是不够的。** 它自己就是个 PRAGMA,学生一句 `PRAGMA query_only=0`
|
||
* 就能关掉它 —— 旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒了,所以没这个洞。
|
||
* 这里靠 runStudent 的逐语句守卫补上:学生 SQL 里的 PRAGMA 一律拒绝。
|
||
* 2. **超时粒度是「一条语句」。** deadline 只在语句之间查,单条语句(递归 CTE、
|
||
* 大 CROSS JOIN)一旦进了 step() 就没法从 JS 里打断。stock sql.js 的 wasm 没导出
|
||
* sqlite3_progress_handler / sqlite3_interrupt / sqlite3_set_authorizer / sqlite3_limit
|
||
* (已核对导出表,别再去找了),要用就得自己编 wasm。所以真正的硬上限是父进程的
|
||
* SIGKILL:`./index.ts` 收到 `@phase:student` 标记后会把兜底时限收到「题目时限 + 2s」,
|
||
* 跑飞的学生语句最多多占这么久,而不是整个作业预算。
|
||
*/
|
||
|
||
import initSqlJs, { type Database, type SqlJsStatic } from "sql.js"
|
||
import { readFileSync } from "node:fs"
|
||
|
||
import { JudgeStatus, type JudgeStatusValue } from "../status"
|
||
|
||
/** 单结果集/单表最大行数,防 CROSS JOIN 撑爆内存 */
|
||
const ROW_LIMIT = 10_000
|
||
/** 题目页展示的行数上限(示例数据/期望结果) */
|
||
const DISPLAY_ROW_LIMIT = 20
|
||
const ERROR_MESSAGE_MAX_LEN = 200
|
||
|
||
/** prepare 阶段的语法类错误,映射为 COMPILE_ERROR */
|
||
const SYNTAX_ERROR_MARKERS = ["syntax error", "unrecognized token", "incomplete input"]
|
||
|
||
export class SqlCaseError extends Error {
|
||
constructor(readonly result: JudgeStatusValue, readonly detail: string) {
|
||
super(detail)
|
||
}
|
||
}
|
||
|
||
let cached: SqlJsStatic | null = null
|
||
|
||
export async function sqlEngine() {
|
||
if (cached) return cached
|
||
const binary = readFileSync(require.resolve("sql.js/dist/sql-wasm.wasm"))
|
||
// @types/sql.js 把 wasmBinary 标成 ArrayBuffer,实际 emscripten 接受 TypedArray;
|
||
// 这里传 Uint8Array 是运行时正确的写法,类型上断言掉
|
||
cached = await initSqlJs({ wasmBinary: binary as unknown as ArrayBuffer })
|
||
return cached
|
||
}
|
||
|
||
function truncate(message: string) {
|
||
return message.length > ERROR_MESSAGE_MAX_LEN
|
||
? `${message.slice(0, ERROR_MESSAGE_MAX_LEN)}...`
|
||
: message
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 值归一化
|
||
|
||
type Canonical = string
|
||
|
||
/**
|
||
* 值归一化并打类型标签,防止 NULL/"NULL"、1/"1" 碰撞;数值统一比对
|
||
* (1 == 1.0,浮点保留 6 位有效数字)。与旧 `_canonical_value` 同口径。
|
||
*/
|
||
function canonicalValue(value: unknown): Canonical {
|
||
if (value === null || value === undefined) return "null"
|
||
if (value instanceof Uint8Array) return `blob:${Buffer.from(value).toString("hex")}`
|
||
if (typeof value === "number") {
|
||
if (Number.isInteger(value) && Math.abs(value) < 2 ** 53) return `num:${value}`
|
||
// Python 的 format(v, ".6g")
|
||
return `num:${formatG6(value)}`
|
||
}
|
||
if (typeof value === "bigint") return `num:${value}`
|
||
return `str:${String(value)}`
|
||
}
|
||
|
||
/** 等价于 Python 的 format(v, ".6g") */
|
||
function formatG6(value: number) {
|
||
const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)))
|
||
if (exponent < -4 || exponent >= 6) {
|
||
return value.toExponential(5).replace(/\.?0+e/, "e").replace(/e([+-])(\d)$/, "e$10$2")
|
||
}
|
||
const text = value.toPrecision(6)
|
||
return text.includes(".") ? text.replace(/\.?0+$/, "") : text
|
||
}
|
||
|
||
function canonicalRow(row: unknown[]) {
|
||
return row.map(canonicalValue).join("")
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 执行
|
||
|
||
interface ResultSet {
|
||
columns: number
|
||
rows: string[]
|
||
}
|
||
|
||
/** 引擎侧的资源限制。学生的每条语句前都要重放一遍,见 runStudent */
|
||
function applyLimits(db: Database, memoryLimitMb: number) {
|
||
const limit = Math.max(Math.trunc(memoryLimitMb), 1)
|
||
// 4096B/页 × 256 页/MB,超限报 "database or disk is full"
|
||
db.run(`PRAGMA max_page_count=${limit * 256}`)
|
||
}
|
||
|
||
function newDatabase(SQL: SqlJsStatic, memoryLimitMb: number) {
|
||
const db = new SQL.Database()
|
||
db.run("PRAGMA page_size=4096")
|
||
applyLimits(db, memoryLimitMb)
|
||
return db
|
||
}
|
||
|
||
/** sql.js 的 iterateStatements 没进 @types/sql.js,这里补上类型 */
|
||
interface PreparedStatement {
|
||
step(): boolean
|
||
get(): unknown[]
|
||
getColumnNames(): string[]
|
||
getSQL(): string
|
||
getNormalizedSQL(): string
|
||
free(): void
|
||
}
|
||
|
||
function iterate(db: Database, script: string): Iterable<PreparedStatement> {
|
||
return (db as unknown as {
|
||
iterateStatements(sql: string): Iterable<PreparedStatement>
|
||
}).iterateStatements(script)
|
||
}
|
||
|
||
/**
|
||
* 取语句的首关键字。优先用 sqlite3_normalized_sql —— 归一化由 SQLite 自己做,
|
||
* 注释、大小写、空白都已抹平(`/*x*/ pragma Query_Only = 0` → `PRAGMA query_only=?`),
|
||
* 比在原文上自己做词法猜测可靠得多。
|
||
*/
|
||
function leadingKeyword(statement: PreparedStatement) {
|
||
let text = ""
|
||
try {
|
||
text = statement.getNormalizedSQL() ?? ""
|
||
} catch {
|
||
text = ""
|
||
}
|
||
// 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE,退回到原文剥注释
|
||
if (!text) {
|
||
text = statement.getSQL().replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ")
|
||
}
|
||
return text.trimStart().split(/[\s(;]/, 1)[0]?.toUpperCase() ?? ""
|
||
}
|
||
|
||
/**
|
||
* 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 null。
|
||
*
|
||
* 用 sql.js 的 iterateStatements(底层是 sqlite3_prepare_v2 逐条推进),
|
||
* 比旧实现手写的分号切分更准 —— 字符串和注释里的分号天然不会误切。
|
||
*
|
||
* `guard` 在每条语句 step 之前调用,用来拦学生的 PRAGMA 并重放限制。
|
||
*/
|
||
function executeStatements(
|
||
db: Database,
|
||
script: string,
|
||
deadline: number,
|
||
guard?: (statement: PreparedStatement) => void,
|
||
): ResultSet | null {
|
||
let last: ResultSet | null = null
|
||
for (const statement of iterate(db, script)) {
|
||
try {
|
||
if (Date.now() > deadline) throw new Error("interrupted")
|
||
guard?.(statement)
|
||
const names = statement.getColumnNames()
|
||
if (names.length > 0) {
|
||
const rows: string[] = []
|
||
while (statement.step()) {
|
||
rows.push(canonicalRow(statement.get()))
|
||
if (rows.length > ROW_LIMIT) {
|
||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT} 行`)
|
||
}
|
||
}
|
||
last = { columns: names.length, rows }
|
||
} else {
|
||
while (statement.step()) { /* 无结果集语句,推进到结束 */ }
|
||
}
|
||
} finally {
|
||
statement.free()
|
||
}
|
||
}
|
||
return last
|
||
}
|
||
|
||
/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */
|
||
function dumpTables(db: Database) {
|
||
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[]))
|
||
if (rows.length > ROW_LIMIT) {
|
||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `表 ${table} 超过 ${ROW_LIMIT} 行`)
|
||
}
|
||
state[String(table)] = {
|
||
// 空表 exec 不返回结果,列数用 table_info 兜底
|
||
columns: first?.columns.length ?? tableColumnCount(db, quoted),
|
||
rows: rows.sort(),
|
||
}
|
||
}
|
||
return state
|
||
}
|
||
|
||
function tableColumnCount(db: Database, quotedTable: string) {
|
||
return db.exec(`PRAGMA table_info("${quotedTable}")`)[0]?.values.length ?? 0
|
||
}
|
||
|
||
function queryColumn(db: Database, sql: string) {
|
||
return (db.exec(sql)[0]?.values ?? []).map((row) => row[0])
|
||
}
|
||
|
||
function trustedErrorText(message: string) {
|
||
if (message.includes("interrupted")) return "超时"
|
||
return truncate(message)
|
||
}
|
||
|
||
/** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */
|
||
function executeTrusted(db: Database, script: string, deadline: number, prefix: string) {
|
||
try {
|
||
return executeStatements(db, script, deadline)
|
||
} catch (error) {
|
||
if (error instanceof SqlCaseError) {
|
||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${error.detail}`)
|
||
}
|
||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${trustedErrorText(String((error as Error).message))}`)
|
||
}
|
||
}
|
||
|
||
/** 带防护执行学生 SQL,异常映射为学生级 JudgeStatus */
|
||
function runStudent(
|
||
db: Database,
|
||
script: string,
|
||
mode: string,
|
||
deadline: number,
|
||
memoryLimitMb: number,
|
||
) {
|
||
// 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
|
||
if (mode === "query") db.run("PRAGMA query_only=1")
|
||
try {
|
||
const last = executeStatements(db, script, deadline, (statement) => {
|
||
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
|
||
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
|
||
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
|
||
if (leadingKeyword(statement) === "PRAGMA") {
|
||
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "禁止使用 PRAGMA 语句")
|
||
}
|
||
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
|
||
applyLimits(db, memoryLimitMb)
|
||
if (mode === "query") db.run("PRAGMA query_only=1")
|
||
})
|
||
if (mode === "query") return last
|
||
return dumpTables(db)
|
||
} catch (error) {
|
||
if (error instanceof SqlCaseError) throw error
|
||
const message = String((error as Error).message)
|
||
if (message.includes("interrupted")) {
|
||
throw new SqlCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时")
|
||
}
|
||
if (message.includes("database or disk is full")) {
|
||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制")
|
||
}
|
||
// WASM 堆触顶(zeroblob/group_concat 构造出的超大单值)或 SQLite 自身的长度上限
|
||
if (message.includes("too big") || message.includes("out of memory") || message.includes("Aborted")) {
|
||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
|
||
}
|
||
if (message.includes("readonly database")) {
|
||
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "本题为查询题,禁止修改数据或表结构(INSERT/UPDATE/DELETE/CREATE 等)")
|
||
}
|
||
if (SYNTAX_ERROR_MARKERS.some((marker) => message.includes(marker))) {
|
||
throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message))
|
||
}
|
||
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message))
|
||
} finally {
|
||
if (mode === "query") {
|
||
try { db.run("PRAGMA query_only=0") } catch { /* 连接可能已不可用 */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
function compare(
|
||
expected: unknown,
|
||
actual: unknown,
|
||
mode: string,
|
||
orderSensitive: boolean,
|
||
) {
|
||
if (mode === "query") {
|
||
const exp = expected as ResultSet
|
||
const act = actual as ResultSet
|
||
if (exp.columns !== act.columns) return false
|
||
if (orderSensitive) return exp.rows.join("") === act.rows.join("")
|
||
return [...exp.rows].sort().join("") === [...act.rows].sort().join("")
|
||
}
|
||
return JSON.stringify(expected) === JSON.stringify(actual)
|
||
}
|
||
|
||
export interface RunCaseOptions {
|
||
mode: "query" | "modify"
|
||
orderSensitive: boolean
|
||
timeLimitMs: number
|
||
memoryLimitMb: number
|
||
/** 阶段回调,子进程据此写 stderr 标记,父进程据此收紧兜底 SIGKILL 时限 */
|
||
onPhase?: (phase: "prepare" | "student") => void
|
||
}
|
||
|
||
/**
|
||
* 受信脚本(初始化 + 标准答案)**合计**的墙钟预算。
|
||
* 父进程按同一口径算兜底时限,两边必须用这一个函数,别各写各的。
|
||
*/
|
||
export function trustedBudgetMs(timeLimitMs: number) {
|
||
return Math.max(timeLimitMs * 5, 10_000)
|
||
}
|
||
|
||
/** 题目页展示数据的墙钟预算 */
|
||
export const DISPLAY_BUDGET_MS = 10_000
|
||
|
||
export interface CaseResult {
|
||
test_case: string
|
||
result: JudgeStatusValue
|
||
cpu_time: number
|
||
real_time: number
|
||
memory: number
|
||
signal: number
|
||
exit_code: number
|
||
error: number
|
||
output_md5: string
|
||
error_message: string | null
|
||
}
|
||
|
||
/**
|
||
* 判一个测试点,返回与外部 judger 单测试点同构的结构。
|
||
* 学生错误(CE/WA/TLE/MLE/RE)体现在返回值里;出题配置错误抛 SqlCaseError(SYSTEM_ERROR)。
|
||
*/
|
||
export async function runCase(
|
||
initSql: string,
|
||
refSql: string,
|
||
studentSql: string,
|
||
options: RunCaseOptions,
|
||
): Promise<CaseResult> {
|
||
const SQL = await sqlEngine()
|
||
options.onPhase?.("prepare")
|
||
// 受信脚本的运行上限放宽,避免出题数据较大时误报;仍防子进程永久阻塞。
|
||
// 三段受信执行(两次初始化 + 一次标准答案)共用同一个 deadline,
|
||
// 这样"受信阶段总耗时"有确定上限,父进程才能算出匹配的兜底时限。
|
||
const trustedDeadline = Date.now() + trustedBudgetMs(options.timeLimitMs)
|
||
|
||
let expected: unknown
|
||
const refDb = newDatabase(SQL, options.memoryLimitMb)
|
||
try {
|
||
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
|
||
const last = executeTrusted(refDb, refSql, trustedDeadline, "标准答案执行失败")
|
||
if (options.mode === "query") {
|
||
expected = last
|
||
} else {
|
||
try {
|
||
expected = dumpTables(refDb)
|
||
} catch (error) {
|
||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超出限制: ${(error as SqlCaseError).detail}`)
|
||
}
|
||
}
|
||
} finally {
|
||
refDb.close()
|
||
}
|
||
if (options.mode === "query" && expected === null) {
|
||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集")
|
||
}
|
||
|
||
const result: CaseResult = {
|
||
test_case: "",
|
||
result: JudgeStatus.ACCEPTED,
|
||
cpu_time: 0,
|
||
real_time: 0,
|
||
memory: 0,
|
||
signal: 0,
|
||
exit_code: 0,
|
||
error: 0,
|
||
output_md5: "",
|
||
error_message: null,
|
||
}
|
||
|
||
const studentDb = newDatabase(SQL, options.memoryLimitMb)
|
||
let actual: unknown
|
||
let elapsed = 0
|
||
try {
|
||
executeTrusted(studentDb, initSql, trustedDeadline, "初始化脚本执行失败")
|
||
options.onPhase?.("student")
|
||
const start = Date.now()
|
||
try {
|
||
actual = runStudent(
|
||
studentDb,
|
||
studentSql,
|
||
options.mode,
|
||
start + options.timeLimitMs,
|
||
options.memoryLimitMb,
|
||
)
|
||
} catch (error) {
|
||
elapsed = Date.now() - start
|
||
const failure = error as SqlCaseError
|
||
return { ...result, result: failure.result, error_message: failure.detail, cpu_time: elapsed, real_time: elapsed }
|
||
}
|
||
elapsed = Date.now() - start
|
||
} finally {
|
||
studentDb.close()
|
||
}
|
||
|
||
result.cpu_time = elapsed
|
||
result.real_time = elapsed
|
||
if (options.mode === "query" && (actual === null || actual === undefined)) {
|
||
result.result = JudgeStatus.WRONG_ANSWER
|
||
result.error_message = "提交的 SQL 未产生查询结果集"
|
||
} else if (!compare(expected, actual, options.mode, options.orderSensitive)) {
|
||
result.result = JudgeStatus.WRONG_ANSWER
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 题目页展示数据
|
||
|
||
function displayValue(value: unknown) {
|
||
if (value instanceof Uint8Array) return Buffer.from(value).toString("hex")
|
||
return value as string | number | null
|
||
}
|
||
|
||
interface DisplayTable {
|
||
name: string
|
||
columns: { name: string; type: string }[]
|
||
rows: (string | number | null)[][]
|
||
total_rows: number
|
||
truncated: boolean
|
||
dropped?: boolean
|
||
}
|
||
|
||
/** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */
|
||
function dumpDisplayTables(db: Database, only?: Set<string>): DisplayTable[] {
|
||
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||
const tables: DisplayTable[] = []
|
||
for (const raw of names) {
|
||
const name = String(raw)
|
||
if (only && !only.has(name)) continue
|
||
const quoted = name.replaceAll('"', '""')
|
||
const columns = (db.exec(`PRAGMA table_info("${quoted}")`)[0]?.values ?? []).map((row) => ({
|
||
name: String(row[1]),
|
||
type: String(row[2] ?? ""),
|
||
}))
|
||
const total = Number(db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0)
|
||
const rows = (db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]?.values ?? [])
|
||
.map((row) => (row as unknown[]).map(displayValue))
|
||
tables.push({ name, columns, rows, total_rows: total, truncated: total > DISPLAY_ROW_LIMIT })
|
||
}
|
||
return tables
|
||
}
|
||
|
||
/**
|
||
* 给查询结果的列名标上类型:按列名回查数据表的声明类型,与数据表展示同源(如 VARCHAR(20))。
|
||
* 表达式/聚合列(COUNT(*)、别名等)在数据表里无同名列,类型留空(前端隐藏)。
|
||
*/
|
||
function queryResultColumns(names: string[], tables: DisplayTable[]) {
|
||
const types = new Map<string, string>()
|
||
for (const table of tables) {
|
||
for (const column of table.columns) types.set(column.name, column.type)
|
||
}
|
||
return names.map((name) => ({ name, type: types.get(name) ?? "" }))
|
||
}
|
||
|
||
/** 生成题目页展示数据:初始数据表 + 期望结果。失败一律抛 SqlCaseError(出题配置问题) */
|
||
export async function buildDisplay(
|
||
initSql: string,
|
||
refSql: string,
|
||
mode: "query" | "modify",
|
||
memoryLimitMb = 64,
|
||
) {
|
||
const SQL = await sqlEngine()
|
||
const db = newDatabase(SQL, memoryLimitMb)
|
||
const deadline = Date.now() + DISPLAY_BUDGET_MS
|
||
try {
|
||
executeTrusted(db, initSql, deadline, "初始化脚本执行失败")
|
||
const tables = dumpDisplayTables(db)
|
||
|
||
if (mode === "query") {
|
||
let expected: unknown = null
|
||
try {
|
||
for (const statement of iterate(db, refSql)) {
|
||
try {
|
||
const names = statement.getColumnNames()
|
||
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
|
||
const rows: unknown[][] = []
|
||
while (statement.step()) {
|
||
rows.push(statement.get())
|
||
if (rows.length > ROW_LIMIT) {
|
||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超过 ${ROW_LIMIT} 行`)
|
||
}
|
||
}
|
||
expected = {
|
||
columns: queryResultColumns(names, tables),
|
||
rows: rows.slice(0, DISPLAY_ROW_LIMIT).map((row) => row.map(displayValue)),
|
||
total_rows: rows.length,
|
||
truncated: rows.length > DISPLAY_ROW_LIMIT,
|
||
}
|
||
} finally {
|
||
statement.free()
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof SqlCaseError) throw error
|
||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案执行失败: ${trustedErrorText(String((error as Error).message))}`)
|
||
}
|
||
if (expected === null) {
|
||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集")
|
||
}
|
||
return { tables, expected }
|
||
}
|
||
|
||
const before = dumpTables(db)
|
||
executeTrusted(db, refSql, deadline, "标准答案执行失败")
|
||
const after = dumpTables(db)
|
||
const changed = new Set<string>()
|
||
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||
if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name)
|
||
}
|
||
if (changed.size === 0) {
|
||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置")
|
||
}
|
||
const changedTables = dumpDisplayTables(db, changed)
|
||
// 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」)
|
||
const existing = new Set(changedTables.map((table) => table.name))
|
||
for (const table of tables) {
|
||
if (changed.has(table.name) && !existing.has(table.name)) {
|
||
changedTables.push({ ...table, rows: [], total_rows: 0, truncated: false, dropped: true })
|
||
}
|
||
}
|
||
return { tables, expected: { changed_tables: changedTables } }
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|