|
|
3f6b4102c9
|
feat(题目): 学生题目页恢复「要求」展示,走新的 astRequirements 字段
Deploy / deploy (push) Has been cancelled
旧后端的 ProblemSerializer.Meta.exclude 没排掉 ast_rules,学生拿到的是规则原文,
题目页上那块「要求」(「if 条件 出现 2 次」之类)是显示的。阶段 3 泄露评审刻意
收掉了它,只留 hasAstRules 布尔值 —— 报告里当时就写了:
「新的更安全,但如果 ojnext 有地方读 ast_rules 的具体内容(比如提示"必须用
for 循环"),需要补个专门的字段。」
前端确实有(ProblemContent.vue 的 astRulesForDisplay + 整块渲染),但那个字段
一直没补。结果是这块在 OJ2 上**永远拿不到数据**,代码还在、没人报错。12 道题
受影响;学生只有提交失败后才能从 statistic_info.ast_results 里看到要求。
现在按评审自己的建议补上:oj 侧题目详情(含比赛题)多一个 astRequirements,
**只有渲染要用的两个字段**:
{ description: "if 条件 出现 2 次", kind: "count" }
文案由后端生成,engine / target 这些内部字段不出现在响应里 —— 收紧保留,
展示恢复。实测响应里搜不到 "engine" 也搜不到 "if_statement"。
## 顺带合掉一堆重复
描述文案原来有两份几乎一样的实现:判题机 ast.ts 里一份(写进 ast_results)、
ProblemContent.vue 里一份(题目页用)。现在统一成 ast.ts 的 describeAstRule,
两边共用。差异只在 min/max 同时给出时的措辞(生产库里没有这种规则,实际输出
逐字不变),另外判题机现在也能用上节点类型的中文名 —— 没写 label 的规则以前
判题结果里显示 `必须使用 function_definition`,现在是 `必须使用 函数定义`。
节点类型中文名那 15 条原来在 AstRulesEditor.vue(下拉 options)和
ProblemContent.vue(NODE_TARGET_LABELS)各手抄一份,收进契约的
AST_NODE_TARGET_LABELS,编辑器的下拉现在从它生成。
前端删掉 NODE_TARGET_LABELS / ruleDescription / ruleTagType 共 ~80 行,
`Problem` 类型里那个 oj 侧根本不下发的 astRules 幽灵字段也去掉了。顺带修掉
一处潜在重复渲染:原来 message 非空时 ruleDescription 返回 message、模板里
又单独渲染一次 message(生产库 message 全是空串,所以没露出来)。
## 一个坑:契约里差点搞出循环引用
astRequirements 一开始放在 admin.ts,problem.ts 去 import 它 —— 而 admin.ts
本来就 import problem.ts。**tsc 一声不吭地过了**,运行时才炸:
ReferenceError: Cannot access 'astRequirementsSchema' before initialization
所以整块 AST schema 从 admin.ts 挪到了 problem.ts(本来也是题目域的东西),
admin.ts 反过来从那边取。这类环 tsc 抓不到,加跨文件 schema 引用时得实跑一次。
## 验证
tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打:
- 把生产库那条规则种进本地库,匿名请求 /api/problems/1001:astRequirements 是
`{"Python3":[{"description":"if 条件 出现 2 次","kind":"count"}, ...]}`,
响应里没有 astRules、没有 engine、没有 if_statement。
- 浏览器打开题目页,「要求」那块活了:`要求 | if 条件 出现 2 次 | else 子句 出现 2 次`。
- 后台编辑页展开「代码规则检查」,两条规则正常渲染成
`出现次数 | if 条件 | 精确`,下拉选项(现在从契约生成)正确。
- 直接调 describeAstRule / checkAst / astRequirements:三种 kind 分类正确,
engine 认不出的规则被丢掉,null 和非对象都回落成 null。
- oj 侧 3 页 + 后台 4 页走查无重定向、console 无报错。
冒烟改动已还原(problem 2 的 ast_rules 复位成 null)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-08-25 23:27:40 -06:00 |
|
|
|
f919d41199
|
refactor(契约): AST 代码要求收进契约,三份各写各的合成一份
同一个形状原来在三个地方各定义了一份,三份都不一样:
apps/api/src/judge/ast.ts engine target outer inner label message exact min max
apps/web/src/utils/types.ts engine target message min max
AstRulesEditor.vue(本地) engine target label message exact min max
ProblemContent.vue(本地) engine target label message exact min max
判题机那份是真的(evaluateRule 按 engine 分支读哪几个字段),契约里则压根没有,
`astRules: z.unknown()`。后果是后台编辑器写得出 label 和 exact、判题机也认,
但 utils/types.ts 那个类型描述不了它们 —— 生产库 12 道带 AST 规则的题里,
15 条规则带 label、6 条带 exact,全都在类型之外。
现在契约里一份 astRuleSchema,四处都指向它。engine 收成枚举,列全判题机
实现的十种。**存量数据核过**:12 道题逐条过新 schema,12/12 通过。
顺带三件:
- `astRules` 的响应和请求 schema 从 z.unknown() 换成 astRulesSchema。之前
engine 写错一个字母能存进去,判题机 evaluateRule 走 `default: return null`
静默跳过 —— 老师设了规则、规则不生效、没有任何提示。现在保存时就 400,
错误信息把十个合法值列出来。
- judge/run.ts 的 astRulesForLanguage 原来是整片 `rules as AstRule[]` 硬转,
改成逐条 safeParse:认不出的丢掉那一条,行为和 evaluateRule 的 default 分支
一致,只是提前到读取处,也不再骗类型系统。
- 判题机实现了 must_have_nesting,但后台编辑器没有对应选项,目前只能手工造
数据才用得上。枚举里留着并加了注释,没有顺手去补 UI(那是加功能不是清理)。
## 验证
tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打:
- 把生产库那条带 label/exact 的规则种进本地库,后台题目详情 200、字段齐全;
原样 PUT 回去 200,库里 label 和 exact 都在(旧类型描述不了的那两个)。
- engine 传 "must_do_magic" → 400,错误信息列出十个合法值。
- 直接调 checkAst:三条规则(两条合法 + 一条 engine 认不出)进去,判题机收下
两条;两个 if/else 的代码 passed=true,一个的 passed=false,描述文案
「if 条件 出现 2 次」正确用上了 label。
冒烟改动已还原(problem 2 的 ast_rules 复位成 null,测试标签删掉)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-08-25 23:15:52 -06:00 |
|
|
|
c59ecd6dbb
|
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>
|
2026-08-08 02:27:35 -06:00 |
|
|
|
ce21a2bb8f
|
feat(阶段5): 让 bun build --compile 的产物真正自足
编译产物拿到没有 node_modules 的目录里跑,原来是一路崩的 —— 而且**在仓库
目录里跑时全都正常**,因为它顺着 cwd 找到了 node_modules,假装没事。
这类问题只会在服务器上第一次启动时暴露。逐个堵掉:
- `import.meta.dir` 在二进制里恒为 `/$bunfs/root`,往上三级就是文件系统根:
`data/test_case` 悄悄变成 `/data/test_case`,`.env` 去读 `/.env`。
新增 runtime.ts 显式分叉:编译后按 cwd,开发时按仓库根(后者不能改,
compose 挂给判题沙箱的是仓库根的 data/test_case)。
- sql.js / tree-sitter 的 wasm、jieba 的 .node 和 4.8MB 词典,
原来都靠运行时 require.resolve / Bun.resolveSync / __dirname 去 node_modules 里找。
全部改成 `with { type: "file" }` 内嵌成资源。jieba 尤其绕:它的 index.js
运行时探测平台再 require 子包,dict.js 用 __dirname 读 dict.txt,两条都
依赖磁盘布局,所以单独包了 vendor/jieba.ts 直接 require 内嵌的 .node。
- SQL 判题要 spawn 一个能被 SIGKILL 的子进程,原来 spawn 的是 child.ts 的路径,
编译后那个文件不存在。改成二进制自己按 argv 分发:新增 main.ts 作为唯一入口,
serve / worker / sql-child 三个子命令,镜像里只需要一份运行时。
## 顺带修掉一个能拖垮生产的坑
「spawn 自己」意味着只要 argv 分发这一环出问题(子命令改名没同步、compose 里
command 写错、拿别的入口编了二进制),「起自己」就变成「把整个程序再跑一遍」,
而那一遍又会 spawn 一个自己 —— 指数增长。
这不是假想:开发时用一个没有分发器的临时入口编了个二进制,一跑就递归 fork
105MB 的进程,几秒触发 global OOM,内核把开发机的终端杀了
(`selftest invoked oom-killer ... Killed process ... Alacritty`)。
同样的错误发生在服务器上就是判题机连同数据库一起拖死。
加了递归闸:父进程 spawn 时打 OJ2_SQL_CHILD 标记,带标记的进程一律拒绝再 spawn,
最多一层就停在一条明确的 SYSTEM_ERROR 上。
## 验证
产物拷进只有它自己一个文件的目录,2G 内存上限下跑,7 项全过:
jieba 分词(自定义词「循环结构」「死循环」命中)、tree-sitter Python3/C 各两条
(含一条**预期失败**的规则做对照,否则「语言没加载成功」和「规则通过」返回值
一模一样,分不出来)、SQL 判题 AC、SQL 只读防护仍拦住 PRAGMA。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-08-07 23:13:57 -06:00 |
|
|
|
0f94e7ecbd
|
fix(阶段4): 两份独立安全评审的 Critical/Important 全部修掉
## 权限边界评审(后台 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>
|
2026-08-07 21:59:42 -06:00 |
|
|
|
e3faa689e7
|
feat(阶段2补课): SQL 判题链路 + 最后两个后台端点
新后端此前完全没有 SQL 判题(旧 judge/sql_runner.py 378 行 + sql_dispatcher.py
113 行无对应实现),阶段 2 纵切时漏了这条与沙箱完全不同的路径。
judge/sql/engine.ts 判题核心,移植自 sql_runner.py,判定口径逐条对齐
judge/sql/child.ts 子进程入口
judge/sql/index.ts 父进程:spawn + 硬超时
judge/run.ts language === "SQL" 时分流,不经判题沙箱
POST admin/sql-test-cases/preview 题目页展示数据预览
POST admin/sql-test-cases/generate AI 按标准答案倒推初始化脚本
题目保存时重新生成 sqlDisplay(对齐旧 generate_sql_display):取测试点 1 的初始化
脚本 + 标准答案跑一遍,失败一律拦下不让保存 —— 展示数据直接决定学生看到的表结构
与期望结果,宁可不保存也不能存错的。
## 防护换了实现,逐条实测
bun:sqlite 没有 authorizer / progress_handler / setlimit,且实测 Worker.terminate()
杀不掉跑飞的查询(原生代码占着线程)。改用「WASM 引擎 + 独立子进程」:
ATTACH → WASM 无宿主文件系统绑定,结构上够不到(比旧的 authorizer 更强)
查询题只读 → PRAGMA query_only=1
超时 → 子进程外部 SIGKILL
单值内存 → 子进程 ulimit -d
八条提交实测:正确→Accepted;列少一个/漏过滤→Wrong Answer;语法错误→Compile Error;
查询题里 INSERT→运行错误并说明;递归 CTE 死循环→CPU 超时;hex(zeroblob(2e8))→内存超限;
attach '/etc/passwd'→打不开。
## 踩到的两个坑(已写进 docs/specs/phase3-coverage.md)
1. ulimit 必须用 -d 不能用 -v。-v 限虚拟地址空间而 JS 引擎预留巨量地址,实测 -v 之下
Bun 退出时有概率 panic(SIGILL),结果早已写出但进程异常终止,父进程读到空串
误判成超时 —— 6 次里坏 2 次。换 -d 后 12/12 稳定。
2. 子进程写完结果直接 SIGKILL 自己,不走 process.exit()——后者仍有清理会撞限额。
至此 admin/api.ts 已无任何指向旧后端的调用。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-08-07 17:03:41 -06:00 |
|
|
|
8c00cdc947
|
feat(阶段3): oj 侧端点铺开(基线提交,未经评审)
由外部 agent (Codex) 在本会话额度中断期间完成。原样提交作为基线,
后续修复单独成 commit,便于区分与回退。
覆盖 oj 侧 65 个端点,新增 9 组路由(account/achievement/ai/classroom/
content/contest/flowchart/problemset/site)与对应 Zod 契约。
已核验:tsc --noEmit 退出码 0;API 可启动;/api/problems 返回真实数据;
judge 与 flowchart worker 均 ready。
未核验:权限边界与数据泄露,评审进行中。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
2026-08-07 01:25:36 -06:00 |
|
|
|
ec274419c3
|
Build Phase 2 judge vertical slice
|
2026-08-06 22:42:39 -06:00 |
|