Files
OJ2/apps/api/src/services/test-case.ts
yuetsh cd5dd16f3b feat(阶段4): 测试用例压缩包上传与下载
POST  admin/test-cases
  GET   admin/problems/:id/test-cases   (返回 zip 二进制)

落盘格式必须与判题沙箱镜像的约定一致(沙箱直接读挂进去的目录),已用真判题验证:
上传 zip → 建题 → 提交 Python 解法 → 沙箱读到用例并判出 Accepted。

安全与健壮性上比旧后端多做的几件事:

- **zip slip 从设计上进不来**:不遍历压缩包条目,只按精确文件名(`N.in`/`N.out`/`N.sql`)
  取内容,条目名一律不参与路径拼接。实测带 `../../etc/passwd` 条目的包能正常处理,
  且只取到 1.in/1.out。
- 单文件 32MB、解压后总量 128MB、测试点数 500 的上限,防 zip bomb 与写满磁盘 ——
  旧后端一概没有,机房那台机器盘写满之后判题也会一起挂。
- 坏 zip 返回 400 而不是 500。

对齐旧后端的细节:CRLF→LF 归一;`stripped_output_md5` 按 Python `bytes.rstrip()`
的口径只剥尾部 ASCII 空白后再算(实测与 hashlib.md5 结果一致);编号从 1 起连续、
遇缺口即停;SQL 包至少 2 个测试点(题目页会展示测试点 1 的期望结果,只有一个时
学生可以对照着硬编码 AC);目录 0710、文件 0640。

## 顺带修掉一个只在判题时才暴露的路径 bug

config 里的相对路径(data/test_case、data/avatar、data/upload)原先按进程 cwd 解析,
而起服务的方式会把 cwd 切到 apps/api/,于是测试点落在 apps/api/data/ 下 ——
但 docker/compose.dev.yml 把**仓库根**的 data/test_case 挂进判题沙箱。两边不是同一个
目录,新传的测试点判题时会「找不到测试数据」,且只在真正判题时才暴露。
改成一律按仓库根解析,实测沙箱能看到新传的目录。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:42:54 -06:00

221 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto"
import { mkdir, chmod, readdir, readFile, writeFile } from "node:fs/promises"
import { resolve } from "node:path"
import { unzipSync, zipSync } from "fflate"
import { config } from "../config"
/**
* 测试点压缩包的解析与落盘。对齐旧 `problem/views/admin.py:TestCaseZipProcessor`。
*
* 落盘格式必须与判题沙箱镜像的约定一致 —— 沙箱直接读挂载进去的目录:
* <test_case_id>/1.in 1.out 2.in 2.out ... info
* `info` 里 `test_cases` 的键是从 "1" 开始的字符串序号。
*/
/** 单个测试点文件上限。机房那台机器盘不大,一个失手的大文件能把判题一起拖挂 */
const MAX_ENTRY_BYTES = 32 * 1024 * 1024
/** 解压后总大小上限,防 zip bomb */
const MAX_TOTAL_BYTES = 128 * 1024 * 1024
/** 测试点数量上限 */
const MAX_CASES = 500
export class TestCaseError extends Error {}
export interface TestCaseEntry {
stripped_output_md5: string
input_size: number
output_size: number
input_name: string
output_name: string
}
/** 等价于 Python 的 bytes.rstrip():只剥尾部 ASCII 空白 */
function rstrip(buffer: Uint8Array) {
const whitespace = new Set([0x20, 0x09, 0x0a, 0x0d, 0x0b, 0x0c])
let end = buffer.length
while (end > 0 && whitespace.has(buffer[end - 1]!)) end -= 1
return buffer.subarray(0, end)
}
/** CRLF → LF与旧后端 `content.replace(b"\r\n", b"\n")` 一致 */
function normalizeNewlines(buffer: Uint8Array) {
const out = new Uint8Array(buffer.length)
let length = 0
for (let i = 0; i < buffer.length; i += 1) {
if (buffer[i] === 0x0d && buffer[i + 1] === 0x0a) continue
out[length] = buffer[i]!
length += 1
}
return out.subarray(0, length)
}
/**
* 从 1 开始找连续编号的测试点,遇到缺口就停。
* 缺口之后的文件一律忽略 —— 与旧 `filter_name_list` 一致:编号断了说明打包出了问题,
* 沉默地跳过一段比按乱序判题安全。
*/
function collectPairs(names: Set<string>) {
const pairs: [string, string][] = []
for (let index = 1; index <= MAX_CASES; index += 1) {
const input = `${index}.in`
const output = `${index}.out`
if (!names.has(input) || !names.has(output)) break
pairs.push([input, output])
}
return pairs
}
function collectSqlScripts(names: Set<string>) {
const scripts: string[] = []
for (let index = 1; index <= MAX_CASES; index += 1) {
const name = `${index}.sql`
if (!names.has(name)) break
scripts.push(name)
}
return scripts
}
export interface ProcessedTestCase {
testCaseId: string
info: TestCaseEntry[]
}
export async function processTestCaseZip(
archive: Uint8Array,
options: { sql?: boolean } = {},
): Promise<ProcessedTestCase> {
let files: Record<string, Uint8Array>
try {
files = unzipSync(archive)
} catch {
throw new TestCaseError("压缩包损坏或不是 zip 格式")
}
// 只按「精确文件名」取内容,不遍历压缩包里的条目 ——
// 条目名一律不参与路径拼接zip slip`../../etc/passwd` 这类条目名)从设计上就进不来。
const names = new Set(Object.keys(files).filter((name) => /^\d+\.(in|out|sql)$/.test(name)))
const selected = options.sql ? collectSqlScripts(names) : collectPairs(names).flat()
if (selected.length === 0) throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点")
if (options.sql && selected.length < 2) {
// 题目页会展示测试点 1 的期望结果,只有一个测试点时学生可以对照着硬编码 AC
throw new TestCaseError("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")
}
let total = 0
const contents = new Map<string, Uint8Array>()
for (const name of selected) {
const raw = files[name]!
if (raw.length > MAX_ENTRY_BYTES) {
throw new TestCaseError(`测试点 ${name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`)
}
const content = normalizeNewlines(raw)
total += content.length
if (total > MAX_TOTAL_BYTES) {
throw new TestCaseError(`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`)
}
contents.set(name, content)
}
const testCaseId = randomId()
const directory = resolve(config.testCaseDirectory, testCaseId)
await mkdir(directory, { recursive: true })
await chmod(directory, 0o710)
for (const [name, content] of contents) {
await writeFile(resolve(directory, name), content)
await chmod(resolve(directory, name), 0o640)
}
const info: TestCaseEntry[] = []
const testCases: Record<string, TestCaseEntry> = {}
if (options.sql) {
// SQL 题:每个 N.sql 是一个测试点的建表+数据脚本,没有期望输出(判题时跑标准答案生成)。
// output_name 复用同名、md5 置空,以兼容前端的测试点表格。
selected.forEach((name, index) => {
const entry: TestCaseEntry = {
stripped_output_md5: "",
input_size: contents.get(name)!.length,
output_size: 0,
input_name: name,
output_name: name,
}
info.push(entry)
testCases[String(index + 1)] = entry
})
} else {
collectPairs(names).forEach(([input, output], index) => {
const outputContent = contents.get(output)!
const entry: TestCaseEntry = {
stripped_output_md5: createHash("md5").update(rstrip(outputContent)).digest("hex"),
input_size: contents.get(input)!.length,
output_size: outputContent.length,
input_name: input,
output_name: output,
}
info.push(entry)
testCases[String(index + 1)] = entry
})
}
const payload: Record<string, unknown> = { test_cases: testCases }
if (options.sql) payload.sql = true
const infoPath = resolve(directory, "info")
await writeFile(infoPath, JSON.stringify(payload, null, 4), "utf8")
await chmod(infoPath, 0o640)
return { testCaseId, info }
}
/** 把一个测试点目录重新打包成 zip 供后台下载 */
export async function packTestCaseZip(testCaseId: string) {
const directory = resolve(config.testCaseDirectory, testCaseId)
let entries: string[]
try {
entries = await readdir(directory)
} catch {
throw new TestCaseError("Test case does not exists")
}
const names = new Set(entries)
const isSql = await readInfo(testCaseId).then((info) => Boolean(info?.sql)).catch(() => false)
const selected = isSql ? collectSqlScripts(names) : collectPairs(names).flat()
const bundle: Record<string, Uint8Array> = {}
for (const name of [...selected, "info"]) {
if (!names.has(name)) continue
bundle[name] = new Uint8Array(await readFile(resolve(directory, name)))
}
return zipSync(bundle)
}
export async function readInfo(testCaseId: string) {
const path = resolve(config.testCaseDirectory, testCaseId, "info")
try {
return JSON.parse(await readFile(path, "utf8")) as {
sql?: boolean
test_cases?: Record<string, TestCaseEntry>
}
} catch {
return null
}
}
/** 读回 SQL 测试点的脚本内容,供后台回显 */
export async function readSqlScripts(testCaseId: string) {
const directory = resolve(config.testCaseDirectory, testCaseId)
const names = collectSqlScripts(new Set(await readdir(directory)))
const scripts: { name: string; content: string }[] = []
for (const name of names) {
scripts.push({ name, content: await readFile(resolve(directory, name), "utf8") })
}
return scripts
}
function randomId() {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join("")
}