diff --git a/judge/dispatcher.py b/judge/dispatcher.py index 19f33a8..c21c4aa 100644 --- a/judge/dispatcher.py +++ b/judge/dispatcher.py @@ -79,6 +79,10 @@ class DispatcherBase(object): class JudgeDispatcher(DispatcherBase): + # 是否占用 JudgeServer 槽位。判完后只有占过槽位的才需要唤醒等待队列, + # 否则会把队首任务 pop 出来又原地退回(SQLJudgeDispatcher 在 worker 内判题,不占槽位) + uses_judge_server = True + def __init__(self, submission_id, problem_id): super().__init__() self.submission = Submission.objects.get(id=submission_id) @@ -216,8 +220,9 @@ class JudgeDispatcher(DispatcherBase): else: self.update_problem_status() - # 至此判题结束,尝试处理任务队列中剩余的任务 - process_pending_task() + # 至此判题结束,释放了 JudgeServer 槽位,尝试处理任务队列中剩余的任务 + if self.uses_judge_server: + process_pending_task() def update_problem_status_rejudge(self): result = str(self.submission.result) diff --git a/judge/sql_dispatcher.py b/judge/sql_dispatcher.py index e0539ca..1ff9585 100644 --- a/judge/sql_dispatcher.py +++ b/judge/sql_dispatcher.py @@ -23,6 +23,9 @@ class SQLProblemConfigError(Exception): class SQLJudgeDispatcher(JudgeDispatcher): + # 在 worker 内用 sqlite3 判题,不经过 ChooseJudgeServer,也就没有槽位可释放 + uses_judge_server = False + def judge(self): Submission.objects.filter(id=self.submission.id).update(result=JudgeStatus.JUDGING) self._push_status(JudgeStatus.JUDGING, "judging") @@ -56,6 +59,15 @@ class SQLJudgeDispatcher(JudgeDispatcher): return cases.append(case) + # 判题给出的中文提示(授权拒绝/超时/内存/无结果集)只存在测试点的 error_message 里, + # 前端只读 statistic_info.err_info,这里把首个失败测试点的提示提上来,否则学生看不到原因 + failed = next((c for c in cases if c["result"] != JudgeStatus.ACCEPTED), None) + if failed and failed["error_message"]: + self.submission.statistic_info["err_info"] = failed["error_message"] + else: + # rejudge 时清掉上一轮的残留提示 + self.submission.statistic_info.pop("err_info", None) + self._process_judge_result({"err": None, "data": cases}) def _system_error(self, message): diff --git a/judge/sql_runner.py b/judge/sql_runner.py index e193230..95e5643 100644 --- a/judge/sql_runner.py +++ b/judge/sql_runner.py @@ -3,7 +3,8 @@ 查询题(mode="query")比对最后一条 SELECT 的结果集; 增删改题(mode="modify")比对执行后所有用户表的最终状态。 学生 SQL 通过 authorizer(禁 ATTACH/PRAGMA,查询题只读)、 -progress_handler(墙钟超时)和 max_page_count(内存上限)三重防护。 +progress_handler(墙钟超时)和 max_page_count + SQLITE_LIMIT_LENGTH(内存上限)三重防护; +受信脚本(初始化/标准答案)不受超时和只读限制,但同样禁止 ATTACH/DETACH。 """ import sqlite3 @@ -26,6 +27,16 @@ _SYNTAX_ERROR_MARKERS = ("syntax error", "unrecognized token", "incomplete input # 两种模式都禁止的授权码:挂载外部库 / 数据库参数 _DENIED_ALWAYS = {sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH, sqlite3.SQLITE_PRAGMA} +# 受信脚本(初始化/标准答案)也必须禁止挂载外部库:ATTACH 能在服务器上读写任意 SQLite 文件, +# 而这些脚本在保存/预览题目时跑在 Django 请求进程里,等于把出题权限提成任意文件读写。 +_TRUSTED_DENIED = {sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH} +_ATTACH_DENIED_HINT = "脚本中禁止使用 ATTACH/DETACH 挂载外部数据库" + + +def _trusted_authorizer(action, arg1, arg2, db_name, trigger): + return sqlite3.SQLITE_DENY if action in _TRUSTED_DENIED else sqlite3.SQLITE_OK + + # 查询题允许的授权码(白名单外一律拒绝,防先 INSERT 伪造数据再 SELECT) _QUERY_MODE_ALLOWED = {getattr(sqlite3, name) for name in ("SQLITE_SELECT", "SQLITE_READ", "SQLITE_FUNCTION", "SQLITE_RECURSIVE", "SQLITE_TRANSACTION") if hasattr(sqlite3, name)} @@ -84,10 +95,17 @@ def _canonical_row(row): def _new_db(memory_limit_mb): + limit_mb = max(int(memory_limit_mb), 1) conn = sqlite3.connect(":memory:", isolation_level=None) # autocommit,脚本行为可预期 conn.execute("PRAGMA page_size=4096") # 4096B/页 × 256 页/MB,超限报 "database or disk is full" - conn.execute(f"PRAGMA max_page_count={max(int(memory_limit_mb), 1) * 256}") + conn.execute(f"PRAGMA max_page_count={limit_mb * 256}") + # max_page_count 只约束数据库页,管不住查询期的单值分配:默认 1GB 上限下 + # 一条 SELECT hex(zeroblob(5e8)) 就能在 worker 进程里吃掉 GB 级内存且秒级返回, + # progress_handler 的指令粒度也拦不到(分配发生在单条 opcode 内)。 + conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, limit_mb * 1024 * 1024) + # 整条连接生命周期内禁止 ATTACH/DETACH(学生 SQL 由 _run_student 换上更严的 authorizer) + conn.set_authorizer(_trusted_authorizer) return conn @@ -122,6 +140,16 @@ def _dump_tables(conn): return state +def _trusted_error_text(e): + """受信脚本的 sqlite 错误转出题人能看懂的提示。""" + msg = str(e) + if "not authorized" in msg or "prohibited" in msg: + return _ATTACH_DENIED_HINT + if "interrupted" in msg: + return "超时" + return _truncate(msg) + + def _execute_trusted(conn, script, deadline, error_prefix): """执行受信脚本,任何失败都是出题问题 → SYSTEM_ERROR。""" conn.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, PROGRESS_STEP) @@ -130,7 +158,7 @@ def _execute_trusted(conn, script, deadline, error_prefix): except SQLCaseError as e: raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"{error_prefix}: {e.message}") except sqlite3.Error as e: - raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"{error_prefix}: {_truncate(e)}") + raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"{error_prefix}: {_trusted_error_text(e)}") finally: conn.set_progress_handler(None, PROGRESS_STEP) @@ -159,6 +187,9 @@ def _run_student(conn, script, mode, deadline): raise SQLCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时") if "database or disk is full" in msg: raise SQLCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制") + # SQLITE_LIMIT_LENGTH 触顶,如 zeroblob/group_concat 构造出的超大单值 + if "too big" in msg: + raise SQLCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制") if "not authorized" in msg or "prohibited" in msg: hint = denied_hints[-1] if denied_hints else "本题禁止使用该语句" raise SQLCaseError(JudgeStatus.RUNTIME_ERROR, hint) @@ -167,7 +198,8 @@ def _run_student(conn, script, mode, deadline): raise SQLCaseError(JudgeStatus.RUNTIME_ERROR, _truncate(msg)) finally: conn.set_progress_handler(None, PROGRESS_STEP) - conn.set_authorizer(None) + # 还原连接级的 ATTACH/DETACH 防护,而不是彻底放开 + conn.set_authorizer(_trusted_authorizer) if mode == "query": return last_result @@ -322,8 +354,7 @@ def build_display(init_sql, ref_sql, mode, *, memory_limit_mb=64): } cursor.close() except sqlite3.Error as e: - msg = "超时" if "interrupted" in str(e) else _truncate(e) - raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"标准答案执行失败: {msg}") + raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"标准答案执行失败: {_trusted_error_text(e)}") finally: conn.set_progress_handler(None, PROGRESS_STEP) if expected is None: diff --git a/problem/views/admin.py b/problem/views/admin.py index 5136247..0b94951 100644 --- a/problem/views/admin.py +++ b/problem/views/admin.py @@ -676,8 +676,8 @@ class TopACTrendAPI(APIView): class SQLTestCasePreviewAPI(APIView): - @validate_serializer(SQLTestCasePreviewSerializer) @problem_permission_required + @validate_serializer(SQLTestCasePreviewSerializer) def post(self, request): data = request.data try: