diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 1fd454113..8886e3798 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -7,7 +7,6 @@ "repository": "https://github.com/hydro-dev/Hydro.git", "dependencies": { "@hydrooj/utils": "workspace:*", - "axios": "^0.27.2", "cac": "^6.7.14", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", @@ -15,6 +14,7 @@ "p-queue": "^7.3.0", "schemastery": "^3.5.3", "shell-quote": "^1.7.3", + "superagent": "^8.0.0", "ws": "^8.8.1" }, "preferUnplugged": true, diff --git a/packages/hydrojudge/src/compile.ts b/packages/hydrojudge/src/compile.ts index 5858f4d9d..fd2f68f5f 100644 --- a/packages/hydrojudge/src/compile.ts +++ b/packages/hydrojudge/src/compile.ts @@ -4,11 +4,13 @@ import { findFileSync } from '@hydrooj/utils/lib/utils'; import checkers from './checkers'; import { CompileError, FormatError } from './error'; import { Execute } from './interface'; -import { CopyInFile, del, run } from './sandbox'; +import { + CopyIn, CopyInFile, del, run, +} from './sandbox'; import { compilerText } from './utils'; export default async function compile( - lang: LangConfig, code: CopyInFile, copyIn: Record = {}, next?: Function, + lang: LangConfig, code: CopyInFile, copyIn: CopyIn = {}, next?: Function, ): Promise { const target = lang.target || 'foo'; const execute = copyIn['execute.sh'] ? '/bin/bash execute.sh' : lang.execute; @@ -46,8 +48,8 @@ const testlibFile = { src: findFileSync('@hydrooj/hydrojudge/vendor/testlib/testlib.h'), }; -const guessLanguage = (filename: string, getLang: Function) => { - const s = filename.replace('@', '.').split('.'); +async function _compile(src: string, type: 'checker' | 'validator' | 'interactor', getLang, copyIn, withTestlib = true) { + const s = src.replace('@', '.').split('.'); let lang; let langId = s.pop(); while (s.length) { @@ -55,31 +57,24 @@ const guessLanguage = (filename: string, getLang: Function) => { if (lang) break; langId = `${s.pop()}.${langId}`; } - return lang; -}; + if (!lang) throw new FormatError(`Unknown ${type} language.`); + if (withTestlib) copyIn = { ...copyIn, 'testlib.h': testlibFile }; + // TODO cache compiled binary + return await compile(lang, { src }, copyIn); +} -export async function compileChecker(getLang: Function, checkerType: string, checker: string, copyIn: any): Promise { +export async function compileChecker(getLang: Function, checkerType: string, checker: string, copyIn: CopyIn) { if (['default', 'strict'].includes(checkerType)) { return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) }; } if (!checkers[checkerType]) throw new FormatError('Unknown checker type {0}.', [checkerType]); - if (checkerType === 'testlib') copyIn['testlib.h'] = testlibFile; - const lang = guessLanguage(checker, getLang); - if (!lang) throw new FormatError('Unknown checker language.'); - // TODO cache compiled checker - return await compile(lang, { src: checker }, copyIn); + return _compile(checker, 'checker', getLang, copyIn, checkerType === 'testlib'); } -export async function compileInteractor(getLang: Function, interactor: string, copyIn: any): Promise { - const lang = guessLanguage(interactor, getLang); - if (!lang) throw new FormatError('Unknown interactor language.'); - // TODO cache compiled checker - return await compile(lang, { src: interactor }, { ...copyIn, 'testlib.h': testlibFile }); +export async function compileInteractor(getLang: Function, interactor: string, copyIn: CopyIn) { + return _compile(interactor, 'interactor', getLang, copyIn); } -export async function compileValidator(getLang: Function, validator: string, copyIn: any): Promise { - const lang = guessLanguage(validator, getLang); - if (!lang) throw new FormatError('Unknown validator language.'); - // TODO cache compiled checker - return await compile(lang, { src: validator }, { ...copyIn, 'testlib.h': testlibFile }); +export async function compileValidator(getLang: Function, validator: string, copyIn: CopyIn) { + return _compile(validator, 'validator', getLang, copyIn); } diff --git a/packages/hydrojudge/src/config.ts b/packages/hydrojudge/src/config.ts index 676ee7332..fa7311fb1 100644 --- a/packages/hydrojudge/src/config.ts +++ b/packages/hydrojudge/src/config.ts @@ -20,11 +20,13 @@ const JudgeSettings = Schema.object({ processLimit: Schema.number().default(32), parallelism: Schema.number().default(2), singleTaskParallelism: Schema.number().default(2), - rerun: Schema.number().default(0), + rerun: Schema.number().description('Re-Run testcase if time-limit-exceeded (max per submission)').default(0), rate: Schema.number().default(1), env: Schema.string().default('PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\nHOME=/w'), host: Schema.any(), - secret: Schema.string().default(String.random(32)), + secret: Schema.string().description('Judge Token Secret').default(String.random(32)), + disable: Schema.boolean().description('Disable builtin judge').default(false), + detail: Schema.boolean().description('Show diff detail').default(true), }); const config = global.Hydro diff --git a/packages/hydrojudge/src/hosts/builtin.ts b/packages/hydrojudge/src/hosts/builtin.ts index c156aa40f..4c8025f8e 100644 --- a/packages/hydrojudge/src/hosts/builtin.ts +++ b/packages/hydrojudge/src/hosts/builtin.ts @@ -6,9 +6,9 @@ import type { JudgeResultBody } from 'hydrooj'; import { end, next } from 'hydrooj/src/handler/judge'; import { Logger } from 'hydrooj/src/logger'; import * as setting from 'hydrooj/src/model/setting'; +import storage from 'hydrooj/src/model/storage'; import * as system from 'hydrooj/src/model/system'; import task from 'hydrooj/src/model/task'; -import storage from 'hydrooj/src/service/storage'; import { processTestdata } from '../cases'; import { getConfig } from '../config'; import { FormatError, SystemError } from '../error'; @@ -26,7 +26,7 @@ const session = { }, getNext(t: Context) { return (data: Partial) => { - logger.debug('Next: %d %o', data); + logger.debug('Next: %o', data); data.rid = t.rid as any; if (data.case) data.case.message ||= ''; next(data); diff --git a/packages/hydrojudge/src/hosts/hydro.ts b/packages/hydrojudge/src/hosts/hydro.ts index f872ed90e..cabc01ac0 100644 --- a/packages/hydrojudge/src/hosts/hydro.ts +++ b/packages/hydrojudge/src/hosts/hydro.ts @@ -1,9 +1,9 @@ /* eslint-disable no-await-in-loop */ import path from 'path'; -import axios from 'axios'; import { ObjectID } from 'bson'; import fs from 'fs-extra'; import PQueue from 'p-queue'; +import superagent from 'superagent'; import WebSocket from 'ws'; import { LangConfig } from '@hydrooj/utils/lib/lang'; import * as sysinfo from '@hydrooj/utils/lib/sysinfo'; @@ -20,13 +20,10 @@ function removeNixPath(text: string) { } export default class Hydro { - config: any; - axios: any; ws: WebSocket; language: Record; - constructor(config) { - this.config = config; + constructor(public config) { this.config.detail = this.config.detail ?? true; this.config.cookie = this.config.cookie || ''; this.config.last_update_at = this.config.last_update_at || 0; @@ -35,10 +32,20 @@ export default class Hydro { this.getLang = this.getLang.bind(this); } + get(url) { + return superagent.get(this.config.server_url + url).set('Cookie', this.config.cookie); + } + + post(url, data) { + return superagent.post(this.config.server_url + url).send(data) + .set('Cookie', this.config.cookie) + .set('Accept', 'application/json'); + } + async init() { await this.setCookie(this.config.cookie || ''); await this.ensureLogin(); - setInterval(() => { this.axios.get(''); }, 30000000); // Cookie refresh only + setInterval(() => { this.get(''); }, 30000000); // Cookie refresh only } async cacheOpen(source: string, files: any[], next?) { @@ -74,18 +81,15 @@ export default class Hydro { log.info(`Getting problem data: ${this.config.host}/${source}`); if (next) next({ message: 'Syncing testdata, please wait...' }); await this.ensureLogin(); - const res = await this.axios.post(`/d/${domainId}/judge/files`, { + const res = await this.post(`/d/${domainId}/judge/files`, { pid: +pid, files: filenames, }); // eslint-disable-next-line no-inner-declarations async function download(name: string) { if (name.includes('/')) await fs.ensureDir(path.join(filePath, name.split('/')[0])); - const f = await this.axios.get(res.data.links[name], { responseType: 'stream' }) - .catch((e) => new Error(`DownloadFail(${name}): ${e.message}`)); - if (f instanceof Error) throw f; const w = fs.createWriteStream(path.join(filePath, name)); - f.data.pipe(w); + this.get(res.body.links[name]).pipe(w); await new Promise((resolve, reject) => { w.on('finish', resolve); w.on('error', (e) => reject(new Error(`DownloadFail(${name}): ${e.message}`))); @@ -93,7 +97,7 @@ export default class Hydro { } const tasks = []; const queue = new PQueue({ concurrency: 10 }); - for (const name in res.data.links) { + for (const name in res.body.links) { tasks.push(queue.add(() => download.call(this, name))); } queue.start(); @@ -106,13 +110,10 @@ export default class Hydro { } async fetchFile(name: string) { - const res = await this.axios.post('judge/code', { id: name }); - const f = await this.axios.get(res.data.url, { responseType: 'stream' }) - .catch((e) => new Error(`DownloadFail(${name}): ${e.message}`)); - if (f instanceof Error) throw f; + const res = await this.post('judge/code', { id: name }); const target = path.join('/tmp/hydro/judge', name.replace(/\//g, '_')); const w = fs.createWriteStream(target); - f.data.pipe(w); + this.get(res.body.url).pipe(w); await new Promise((resolve, reject) => { w.on('finish', resolve); w.on('error', (e) => reject(new Error(`DownloadFail(${name}): ${e.message}`))); @@ -127,7 +128,7 @@ export default class Hydro { return null; } - send(rid: string, key: 'next' | 'end', data: Partial) { + send(rid: string | ObjectID, key: 'next' | 'end', data: Partial) { data.rid = new ObjectID(rid); data.key = key; if (data.case) data.case.message ||= ''; @@ -193,20 +194,11 @@ export default class Hydro { async setCookie(cookie: string) { this.config.cookie = cookie; - this.axios = axios.create({ - baseURL: this.config.server_url, - timeout: 30000, - headers: { - accept: 'application/json', - 'Content-Type': 'application/json', - cookie: this.config.cookie, - }, - }); } async login() { log.info('[%s] Updating session', this.config.host); - const res = await this.axios.post('login', { + const res = await this.post('login', { uname: this.config.uname, password: this.config.password, rememberme: 'on', }); await this.setCookie(res.headers['set-cookie'].join(';')); @@ -214,9 +206,9 @@ export default class Hydro { async ensureLogin() { try { - const res = await this.axios.get('judge/files'); + const res = await this.get('judge/files'); // Redirected to /login - if (res.data.url) await this.login(); + if (res.body.url) await this.login(); } catch (e) { await this.login(); } diff --git a/packages/hydrojudge/src/sandbox.ts b/packages/hydrojudge/src/sandbox.ts index 8fe93a67d..8fa3daee7 100644 --- a/packages/hydrojudge/src/sandbox.ts +++ b/packages/hydrojudge/src/sandbox.ts @@ -8,7 +8,7 @@ import { FormatError, SystemError } from './error'; import { Logger } from './log'; import { SandboxClient } from './sandbox/client'; import { - Cmd, CopyInFile, SandboxResult, SandboxStatus, + Cmd, CopyIn, CopyInFile, SandboxResult, SandboxStatus, } from './sandbox/interface'; import { cmd, parseMemoryMB } from './utils'; @@ -36,7 +36,7 @@ interface Parameter { execute?: string; memory?: number; processLimit?: number; - copyIn?: Record; + copyIn?: CopyIn; copyOut?: string[]; copyOutCached?: string[]; cacheStdoutAndStderr?: boolean; @@ -162,7 +162,8 @@ export async function runPiped(execute0: Parameter, execute1: Parameter): Promis res = await new SandboxClient(getConfig('sandbox_host')).run(body); if (argv.options.showSandbox) logger.debug('%d %s', id, JSON.stringify(res)); } catch (e) { - if (e instanceof FormatError) throw e; + if (e instanceof FormatError || e instanceof SystemError) throw e; + console.error(e); throw new SystemError('Sandbox Error', [e]); } return await Promise.all(res.map((r) => adaptResult(r, {}))) as [SandboxAdaptedResult, SandboxAdaptedResult]; @@ -188,7 +189,8 @@ export async function run(execute: string, params?: Parameter): Promise { - return this.client.post('/run', req).then((res) => res.data); + return superagent.post(`${this.baseURL}/run`).send(req).then((res) => res.body); } public getFile(fileId: string): Promise { - return this.client.get(`/file/${fileId}`).then((res) => res.data); + return superagent.get(`${this.baseURL}/file/${fileId}`).then((res) => res.body); } - public deleteFile(fileId: string): Promise { - return this.client.delete(`/file/${fileId}`); + public deleteFile(fileId: string): Promise { + return superagent.delete(`${this.baseURL}/file/${fileId}`).then((res) => res.body); } public listFiles(): Promise> { - return this.client.get('/file').then((res) => res.data); + return superagent.get(`${this.baseURL}/file`).then((res) => res.body); } public version(): Promise { - return this.client.get('/version').then((res) => res.data); + return superagent.get(`${this.baseURL}/version`).then((res) => res.body); } } diff --git a/packages/hydrojudge/src/sandbox/interface.ts b/packages/hydrojudge/src/sandbox/interface.ts index 095ebb518..9c3a98854 100644 --- a/packages/hydrojudge/src/sandbox/interface.ts +++ b/packages/hydrojudge/src/sandbox/interface.ts @@ -26,6 +26,7 @@ export interface Collector { } export type CopyInFile = LocalFile | MemoryFile | PreparedFile; +export type CopyIn = Record; export type CmdFile = LocalFile | MemoryFile | PreparedFile | Collector | null; /** diff --git a/packages/hydrojudge/src/task.ts b/packages/hydrojudge/src/task.ts index 7851629ad..166d38022 100644 --- a/packages/hydrojudge/src/task.ts +++ b/packages/hydrojudge/src/task.ts @@ -3,7 +3,9 @@ import fs from 'fs-extra'; import { noop } from 'lodash'; import { LangConfig } from '@hydrooj/utils/lib/lang'; import { STATUS } from '@hydrooj/utils/lib/status'; -import type { JudgeResultBody } from 'hydrooj'; +import type { + FileInfo, JudgeRequest, JudgeResultBody, ProblemConfigFile, +} from 'hydrooj'; import { Logger } from 'hydrooj/src/logger'; import readCases from './cases'; import { getConfig } from './config'; @@ -34,14 +36,14 @@ export class JudgeTask { tmpdir: string; input?: string; clean: (() => Promise)[]; - data: any[]; + data: FileInfo[]; folder: string; - config: any; + config: ProblemConfigFile & { problemOwner: number }; next: (data: Partial) => void; end: (data: Partial) => void; env: Record; - constructor(public session: Session, public request) { + constructor(public session: Session, public request: JudgeRequest) { this.stat = {}; this.stat.receive = new Date(); logger.debug('%o', request); @@ -75,7 +77,9 @@ export class JudgeTask { fs.ensureDirSync(this.tmpdir); tmpfs.mount(this.tmpdir, getConfig('tmpfs_size')); logger.info('Submission: %s/%s/%s', host, this.source, this.rid); - await this.doSubmission(); + this.stat.cache_start = new Date(); + if (tid === '000000000000000000000001') await this.doHack(); + else await this.doSubmission(); } catch (e) { if (e instanceof CompileError) { this.next({ compilerText: compilerText(e.stdout, e.stderr) }); @@ -105,7 +109,6 @@ export class JudgeTask { } async doSubmission() { - this.stat.cache_start = new Date(); this.folder = await this.session.cacheOpen(this.source, this.data, this.next); if ((this.code as any).content.startsWith('@@hydro_submission_file@@')) { const id = (this.code as any).content.split('@@hydro_submission_file@@')[1]?.split('#')?.[0]; @@ -129,4 +132,29 @@ export class JudgeTask { if (!judge[type]) throw new FormatError('Unrecognized problemType: {0}', [type]); await judge[type].judge(this); } + + async doHack() { + this.folder = await this.session.cacheOpen(this.source, this.data, this.next); + if ((this.code as any).content.startsWith('@@hydro_submission_file@@')) { + const id = (this.code as any).content.split('@@hydro_submission_file@@')[1]?.split('#')?.[0]; + if (!id) throw new SystemError('Submission File Not Found'); + const target = await this.session.fetchFile(id); + this.code = { src: target }; + this.clean.push(() => fs.remove(target)); + } + this.stat.read_cases = new Date(); + this.config = await readCases( + this.folder, + { + detail: this.session.config.detail, + isSelfSubmission: this.config.problemOwner === this.request.uid, + ...this.config, + }, + { next: this.next, key: md5(`${this.source}/${getConfig('secret')}`) }, + ); + this.stat.judge = new Date(); + const type = typeof this.input === 'string' ? 'run' : this.config.type || 'default'; + if (type !== 'default') throw new FormatError('Cannot hack: only type=default allowed'); + await judge.hack.judge(this); + } } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 891191cd8..6caffea17 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -167,6 +167,7 @@ export interface ProblemConfigFile { cases?: TestCaseConfig[]; subtasks?: SubtaskConfig[]; langs?: string[]; + validator?: string; } export interface ProblemConfig { @@ -298,6 +299,15 @@ export interface RecordDoc { contest?: ObjectID; } +export interface JudgeRequest extends Omit { + priority: number; + type: 'judge'; + rid: ObjectID; + config: ProblemConfigFile & { problemOwner: number }; + data: FileInfo[]; + source: string; +} + export interface ScoreboardNode { type: 'string' | 'rank' | 'user' | 'email' | 'record' | 'records' | 'problem' | 'solved' | 'time' | 'total_score'; value: string; diff --git a/packages/ui-default/components/monaco/languages/yaml.ts b/packages/ui-default/components/monaco/languages/yaml.ts index 50187a913..46d7bfc38 100644 --- a/packages/ui-default/components/monaco/languages/yaml.ts +++ b/packages/ui-default/components/monaco/languages/yaml.ts @@ -49,6 +49,7 @@ const problemConfigSchema = { checker_type: { enum: ['default', 'lemon', 'syzoj', 'hustoj', 'testlib', 'strict', 'qduoj'] }, checker: { type: 'string', pattern: '\\.' }, interactor: { type: 'string', pattern: '\\.' }, + validator: { type: 'string', pattern: '\\.' }, user_extra_files: { type: 'array', items: { type: 'string' } }, judge_extra_files: { type: 'array', items: { type: 'string' } }, cases: { $ref: '#/def/cases' }, diff --git a/packages/utils/lib/cases.ts b/packages/utils/lib/cases.ts index 72980e132..a4149d208 100644 --- a/packages/utils/lib/cases.ts +++ b/packages/utils/lib/cases.ts @@ -8,7 +8,9 @@ export default async function readYamlCases(cfg: Record = {}, check user_extra_files: [], }; if (cfg.type === 'objective') { - if (cfg.checker || cfg.interactor) throw new Error('You cannot use checker or interactor for objective questions'); + if (cfg.checker || cfg.interactor || cfg.validator) { + throw new Error('You cannot use checker, interactor or validator for objective questions'); + } } else { if (cfg.checker) { if (!cfg.checker.includes('.')) { @@ -17,6 +19,7 @@ export default async function readYamlCases(cfg: Record = {}, check if (!config.checker) config.checker = checkFile(cfg.checker, 'Cannot find checker {0}.'); } if (cfg.interactor) config.interactor = checkFile(cfg.interactor, 'Cannot find interactor {0}.'); + if (cfg.validator) config.validator = checkFile(cfg.validator, 'Cannot find validator {0}.'); ['judge', 'user'].forEach((n) => { const conf = cfg[`${n}_extra_files`]; if (!conf) return;