@exercode/problem-utils 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/cli/checkAllProblems.cjs +1 -1
- package/dist/cli/checkAllProblems.cjs.map +1 -1
- package/dist/cli/checkAllProblems.js +1 -1
- package/dist/cli/checkAllProblems.js.map +1 -1
- package/dist/cli/runSingleHarness.cjs +1 -1
- package/dist/cli/runSingleHarness.js +1 -1
- package/dist/helpers/checkProblemDirIsolation.cjs +1 -1
- package/dist/helpers/checkProblemDirIsolation.cjs.map +1 -1
- package/dist/helpers/checkProblemDirIsolation.d.ts +9 -16
- package/dist/helpers/checkProblemDirIsolation.js +1 -1
- package/dist/helpers/checkProblemDirIsolation.js.map +1 -1
- package/dist/helpers/cleanWorkingDirectory.cjs +1 -1
- package/dist/helpers/cleanWorkingDirectory.js +1 -1
- package/dist/helpers/compareExpectedOutputFiles.cjs +2 -0
- package/dist/helpers/compareExpectedOutputFiles.cjs.map +1 -0
- package/dist/helpers/compareExpectedOutputFiles.d.ts +45 -0
- package/dist/helpers/compareExpectedOutputFiles.js +2 -0
- package/dist/helpers/compareExpectedOutputFiles.js.map +1 -0
- package/dist/helpers/compareStdoutAsSpaceSeparatedTokens.cjs +1 -1
- package/dist/helpers/compareStdoutAsSpaceSeparatedTokens.cjs.map +1 -1
- package/dist/helpers/compareStdoutAsSpaceSeparatedTokens.js +1 -1
- package/dist/helpers/compareStdoutAsSpaceSeparatedTokens.js.map +1 -1
- package/dist/helpers/defaultStdioHarness.cjs +1 -1
- package/dist/helpers/defaultStdioHarness.js +1 -1
- package/dist/helpers/findModelAnswerDirs.cjs +1 -1
- package/dist/helpers/findModelAnswerDirs.js +1 -1
- package/dist/helpers/judgeByStaticAnalysis.cjs +1 -1
- package/dist/helpers/judgeByStaticAnalysis.js +1 -1
- package/dist/helpers/readOutputFiles.cjs +1 -1
- package/dist/helpers/readOutputFiles.js +1 -1
- package/dist/helpers/readProblemMarkdownFrontMatter.cjs +1 -1
- package/dist/helpers/readProblemMarkdownFrontMatter.js +1 -1
- package/dist/helpers/readTestCases.cjs +1 -1
- package/dist/helpers/readTestCases.cjs.map +1 -1
- package/dist/helpers/readTestCases.d.ts +6 -0
- package/dist/helpers/readTestCases.js +1 -1
- package/dist/helpers/readTestCases.js.map +1 -1
- package/dist/helpers/runCommandInTemporaryPackageManagerProject.cjs +1 -1
- package/dist/helpers/runCommandInTemporaryPackageManagerProject.js +1 -1
- package/dist/helpers/runCommandInTemporaryPackageManagerProject.js.map +1 -1
- package/dist/helpers/runHarnessProcess.cjs +2 -0
- package/dist/helpers/runHarnessProcess.cjs.map +1 -0
- package/dist/helpers/runHarnessProcess.d.ts +23 -0
- package/dist/helpers/runHarnessProcess.js +2 -0
- package/dist/helpers/runHarnessProcess.js.map +1 -0
- package/dist/helpers/runIsolationCheckInDebugMode.cjs +2 -0
- package/dist/helpers/runIsolationCheckInDebugMode.cjs.map +1 -0
- package/dist/helpers/runIsolationCheckInDebugMode.d.ts +7 -0
- package/dist/helpers/runIsolationCheckInDebugMode.js +2 -0
- package/dist/helpers/runIsolationCheckInDebugMode.js.map +1 -0
- package/dist/helpers/safeFs.cjs +1 -1
- package/dist/helpers/safeFs.js +1 -1
- package/dist/helpers/sandboxUser.cjs +2 -2
- package/dist/helpers/sandboxUser.cjs.map +1 -1
- package/dist/helpers/sandboxUser.d.ts +5 -0
- package/dist/helpers/sandboxUser.js +2 -2
- package/dist/helpers/sandboxUser.js.map +1 -1
- package/dist/helpers/startHttpServer.cjs +1 -1
- package/dist/helpers/startHttpServer.js +1 -1
- package/dist/helpers/temporaryProblemDirCopy.cjs +2 -0
- package/dist/helpers/temporaryProblemDirCopy.cjs.map +1 -0
- package/dist/helpers/temporaryProblemDirCopy.d.ts +18 -0
- package/dist/helpers/temporaryProblemDirCopy.js +2 -0
- package/dist/helpers/temporaryProblemDirCopy.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -1
- package/dist/presets/command.cjs +1 -1
- package/dist/presets/command.cjs.map +1 -1
- package/dist/presets/command.d.ts +35 -3
- package/dist/presets/command.js +1 -1
- package/dist/presets/command.js.map +1 -1
- package/dist/presets/evaluation.cjs +4 -4
- package/dist/presets/evaluation.cjs.map +1 -1
- package/dist/presets/evaluation.js +4 -4
- package/dist/presets/evaluation.js.map +1 -1
- package/dist/presets/guiCommand.cjs +3 -3
- package/dist/presets/guiCommand.cjs.map +1 -1
- package/dist/presets/guiCommand.d.ts +14 -2
- package/dist/presets/guiCommand.js +3 -3
- package/dist/presets/guiCommand.js.map +1 -1
- package/dist/presets/llm.cjs +1 -1
- package/dist/presets/llm.cjs.map +1 -1
- package/dist/presets/llm.d.ts +12 -10
- package/dist/presets/llm.js +1 -1
- package/dist/presets/llm.js.map +1 -1
- package/dist/presets/stdio.cjs +1 -1
- package/dist/presets/stdio.cjs.map +1 -1
- package/dist/presets/stdio.d.ts +3 -1
- package/dist/presets/stdio.js +1 -1
- package/dist/presets/stdio.js.map +1 -1
- package/dist/types/language.cjs +2 -2
- package/dist/types/language.js +2 -2
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sandboxUser.js","names":[],"sources":["../../src/helpers/sandboxUser.ts"],"sourcesContent":["import child_process from 'node:child_process';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\n/**\n * Name of the environment variable through which a judge server tells problem-utils to run\n * untrusted submitted programs as the given unprivileged OS user via sudo.\n *\n * The judge server sets this variable only when it runs the judge harness (e.g. `judge.ts`) as its\n * own trusted user, so that problem files (test cases and the harness itself) stay unreadable to\n * submissions while the submissions themselves run under the sandbox user. When the variable is\n * absent or empty (local development, course authoring, or an all-sandbox judge run), commands run\n * as the current user like before.\n *\n * Contract for the delegating judge server (delegation is Linux-only — sudo user separation and\n * `/home/<user>` homes are provisioned in the judge Docker image; never set this on macOS):\n * - The harness process environment is forwarded to sandboxed submissions (sudo runs with\n * `--preserve-env`), so it must not contain secrets beyond what submissions may see. The one\n * exception is the set glibc strips when executing the setuid `sudo` (secure-execution mode:\n * `LD_*`, `TMPDIR`, `LOCPATH`, `NLSPATH`, `TZDIR`, … — see ld.so(8)); no sudoers setting can\n * recover those, and only `LD_LIBRARY_PATH` is restored, by the wrapper below.\n * - sudoers must let the harness user run arbitrary commands as the sandbox user without a\n * password, pass the environment through, and keep sudo off a pseudo-terminal (a pty would merge\n * stderr into stdout and CRLF-mangle output when the harness happens to run from a terminal):\n * e.g. `Defaults:<harness> !env_reset, !env_delete, !env_check, !secure_path, !use_pty` plus\n * `<harness> ALL=(<sandbox>) NOPASSWD:SETENV: ALL`.\n * - Variables meant for the submitted program rather than for the harness must be passed under\n * {@link SANDBOX_ENV_PREFIX}; problem-utils strips that prefix when it builds a submission's\n * environment.\n * - The sandbox user's home directory must exist at `/home/<sandbox user>` and be writable. It is\n * shared across sequential requests, so the server is responsible for resetting whatever\n * cross-request persistence there matters to it.\n */\nexport const SANDBOX_USER_ENV_NAME = 'EXERCODE_SANDBOX_USER';\n\n// Absolute paths so a submission-influenced `PATH` cannot redirect binaries that the trusted\n// harness user executes (`sudo` is also setuid and must be the real one).\nconst SUDO_PATH = '/usr/bin/sudo';\nconst CHMOD_PATH = '/bin/chmod';\nconst SLEEP_PATH = '/bin/sleep';\n// Minimal environment for trusted helper processes; never forward the harness environment to them.\nconst MINIMAL_ENV = { PATH: '/usr/local/bin:/usr/bin:/bin' } as const;\n\n/**\n * Environment for helper processes the *trusted* harness user runs (`ps`, `xwininfo`, `Xvfb`, …).\n * The judge server overlays caller-supplied variables onto the harness environment, and a sandboxed\n * submission can create executables in its persistent home, so resolving these helpers through the\n * inherited `PATH` would hand the submission code execution as the harness user. Node resolves the\n * command through the child environment's `PATH`, so a fixed `PATH` here pins them to system paths.\n */\nexport function getTrustedHelperEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n // Without delegation the submission runs as this same user, so there is no boundary to defend and\n // pinning `PATH` would only break setups whose X11/`ps` binaries live elsewhere (e.g. /usr/games,\n // /snap/bin, Nix). Keep the inherited environment there, exactly as before delegation existed.\n if (!sandboxUserName) return { ...process.env, ...extraEnv };\n // `PATH` last: a caller passing a whole environment through would otherwise reinstate the\n // submission-influenced one this function exists to replace.\n return { ...extraEnv, PATH: MINIMAL_ENV.PATH };\n}\n\nexport const sandboxUserName = process.env[SANDBOX_USER_ENV_NAME] || undefined;\n\n// Fail fast on the catastrophic misconfiguration where the sandbox user is the harness's own user:\n// cleanup (`killSandboxUserProcesses`) would then SIGKILL the harness itself on the first run.\nif (sandboxUserName && sandboxUserName === os.userInfo().username) {\n throw new Error(\n `${SANDBOX_USER_ENV_NAME} must name a different OS user than the one running the harness (got \"${sandboxUserName}\"). Leave it unset to run everything as the current user.`\n );\n}\n\n/**\n * The deadline supervisor. Absolute under delegation: the wrapper's `exec \"$0\"` resolves it through\n * the submission's own `PATH` (which a judge server may set via {@link SANDBOX_ENV_PREFIX}), so a\n * bare name would let a submission replace the very process that enforces its time limit. The bare\n * name is kept without delegation, where `timeout` may live elsewhere (Homebrew coreutils).\n */\nexport const TIMEOUT_COMMAND = sandboxUserName ? '/usr/bin/timeout' : 'timeout';\n\n/**\n * Wrap a command so it runs as the sandbox user. `umask 0` makes every file the sandboxed process\n * creates world-writable, so the harness user can clean it up without privileges. The wrapper also\n * restores `LD_LIBRARY_PATH`, which ld.so strips across the setuid `sudo` exec. Put `timeout`\n * inside the wrapped command: the harness user cannot signal the root-owned `sudo` process, so an\n * outer timer alone could not stop a runaway submission.\n */\nexport function wrapCommandWithSandboxUser(command: readonly [string, ...string[]]): [string, ...string[]] {\n if (!sandboxUserName) return [...command];\n // Idempotent; see {@link isSandboxWrappedCommand}.\n if (isSandboxWrappedCommand(command)) return [...command];\n return [...buildSandboxWrapperPrefix(sandboxUserName), ...command];\n}\n\nconst SANDBOX_WRAPPER_SCRIPT =\n 'umask 0; if [ -n \"$SANDBOX_LD_LIBRARY_PATH\" ]; then export LD_LIBRARY_PATH=\"$SANDBOX_LD_LIBRARY_PATH\"; fi; exec \"$0\" \"$@\"';\n\nfunction buildSandboxWrapperPrefix(user: string): [string, ...string[]] {\n return [SUDO_PATH, '--preserve-env', '-u', user, '--', 'sh', '-c', SANDBOX_WRAPPER_SCRIPT];\n}\n\n/**\n * Whether the command was already wrapped by {@link wrapCommandWithSandboxUser}. The presets hand\n * custom runners a wrapped command, and a runner may forward it to another helper that wraps too;\n * a nested wrapper's inner `sudo` would run AS the sandbox user, which sudoers does not authorize,\n * so every test case of such a problem would fail only under delegation.\n *\n * Matches the wrapper prefix exactly rather than looking for `sudo` anywhere: a submission-derived\n * command could otherwise carry a literal `/usr/bin/sudo` argument and skip wrapping entirely,\n * which would run the submission as the trusted harness user.\n */\nexport function isSandboxWrappedCommand(command: readonly string[]): boolean {\n if (!sandboxUserName) return false;\n const prefix = buildSandboxWrapperPrefix(sandboxUserName);\n return prefix.every((argument, index) => command[index] === argument);\n}\n\n/**\n * Insert `innerPrefix` (e.g. a `time` measurement prefix) so that it runs INSIDE the sandbox\n * wrapper when `command` is wrapped, and simply in front otherwise. Prefixing a wrapped command\n * from the outside would run `innerPrefix` as the trusted harness user while its arguments (such\n * as an output path) point into a sandbox-writable directory, where a submission can plant a\n * symlink and have the harness truncate an arbitrary file it owns.\n */\nexport function prependInsideSandboxWrapper(\n command: readonly [string, ...string[]],\n innerPrefix: readonly string[]\n): [string, ...string[]] {\n if (!isSandboxWrappedCommand(command)) return [...innerPrefix, ...command] as [string, ...string[]];\n const prefixLength = buildSandboxWrapperPrefix(sandboxUserName as string).length;\n return [...command.slice(0, prefixLength), ...innerPrefix, ...command.slice(prefixLength)] as [string, ...string[]];\n}\n\n/**\n * Environment overrides for sandboxed processes: a writable home and the `LD_LIBRARY_PATH`\n * smuggled past the setuid `sudo` exec (see {@link wrapCommandWithSandboxUser}). Pass the\n * environment the command will actually run with so a caller-supplied `LD_LIBRARY_PATH` survives\n * the exec; the harness's own value is only the fallback.\n */\nexport function getSandboxUserEnvOverrides(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n if (!sandboxUserName) return {};\n const sourceEnv = env ?? process.env;\n const submissionOnlyEnv = unwrapSubmissionOnlyEnv(sourceEnv);\n // A submission-only `LD_LIBRARY_PATH` must be the one smuggled past the exec, not the harness's.\n const ldLibraryPath = submissionOnlyEnv.LD_LIBRARY_PATH ?? sourceEnv.LD_LIBRARY_PATH ?? process.env.LD_LIBRARY_PATH;\n return {\n HOME: `/home/${sandboxUserName}`,\n ...submissionOnlyEnv,\n // Last: the wrapper reads this one, so a caller must not be able to overwrite it.\n ...(ldLibraryPath && { SANDBOX_LD_LIBRARY_PATH: ldLibraryPath }),\n };\n}\n\n/**\n * Prefix under which a delegating judge server passes variables that belong to the submitted\n * program alone. Applying a request's variables to the harness itself would let a submission point\n * e.g. `PATH` or `NODE_OPTIONS` at its own files and get code executed as the trusted harness user,\n * so the server prefixes them and problem-utils strips the prefix back off here, where the\n * environment of a sandboxed submission is built.\n */\nexport const SANDBOX_ENV_PREFIX = 'SANDBOX_ENV_';\n\nfunction unwrapSubmissionOnlyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n const unwrapped: NodeJS.ProcessEnv = {};\n for (const [name, value] of Object.entries(env)) {\n if (!name.startsWith(SANDBOX_ENV_PREFIX)) continue;\n const unwrappedName = name.slice(SANDBOX_ENV_PREFIX.length);\n if (unwrappedName) unwrapped[unwrappedName] = value;\n }\n return unwrapped;\n}\n\n/**\n * Make harness-user-created files under the given path readable, and directories writable, for the\n * sandbox user, so sandboxed programs can read their sources and create outputs next to them.\n */\nexport function makeAccessibleToSandboxUser(targetPath: string): void {\n if (!sandboxUserName) return;\n // Files owned by the sandbox user fail to chmod but are already world-writable (umask 0).\n child_process.spawnSync(CHMOD_PATH, ['-R', 'a+rwX', targetPath], { env: MINIMAL_ENV });\n}\n\n/**\n * Kill the sandbox user's processes with the given signals. The harness user cannot signal another\n * user's processes (nor the root-owned `sudo` wrapper), so this goes through sudo. Killing every\n * sandbox process is safe because the judge server handles one request at a time. The default\n * sends SIGTERM immediately followed by SIGKILL (final cleanup); callers that want a grace period\n * send `['TERM']`, wait, and then send `['KILL']`.\n *\n * Fails closed: a `['TERM']`-only sweep throws when its `sudo` could not be spawned (e.g. a\n * submission exhausted the PID cgroup); a sweep including SIGKILL throws when a sandbox process is\n * still alive, or cannot be listed, afterwards. Reporting success would leave that process running\n * next to the next request on this instance.\n */\nexport function killSandboxUserProcesses(signals: readonly ('TERM' | 'KILL')[] = ['TERM', 'KILL']): void {\n if (!sandboxUserName) return;\n // One direct call per signal instead of one `sh -c 'pkill ...; pkill ...'`: the wrapping shell\n // would run as the sandbox user too, so the first pkill would kill it before the second ran.\n // Exit statuses are not inspected: `sudo` exits with 1 both when `pkill` matched nothing and on\n // its own failures, and a sweep can itself be killed by a concurrent watchdog sweep\n // ({@link startSandboxTimeoutWatchdog}'s `pkill -KILL` matches it). What matters is the outcome.\n const spawnErrors: string[] = [];\n for (const signal of signals) {\n const result = runAsSandboxUser(['pkill', `-${signal}`, '-u', sandboxUserName]);\n if (result.error) spawnErrors.push(`pkill -${signal}: ${result.error.message}`);\n }\n if (!signals.includes('KILL')) {\n // Survivors are expected during the caller's grace period, so only a sweep that never started\n // is a failure here.\n if (spawnErrors.length > 0) {\n throw new Error(`failed to signal ${sandboxUserName} processes: ${spawnErrors.join('; ')}`);\n }\n return;\n }\n const survivors = findSurvivingSandboxUserProcesses();\n if (survivors) throw new Error(`failed to terminate ${sandboxUserName} processes: ${survivors}`);\n}\n\n/**\n * Lists the sandbox user's live processes as the harness user (listing needs no privilege), or\n * `undefined` when none remain. SIGKILL is re-sent between attempts: a child forked after `pkill`\n * scanned `/proc` was never signalled, and a process in uninterruptible sleep dies only once its\n * I/O completes. Just-killed processes linger as zombies until reaped; a zombie row is ignored only\n * when it has a single thread, because a thread-group leader that exited via `pthread_exit` shows\n * as a zombie while its other threads keep running.\n */\nfunction findSurvivingSandboxUserProcesses(): string | undefined {\n for (let attempt = 0; ; attempt++) {\n if (attempt > 0) {\n runAsSandboxUser(['pkill', '-KILL', '-u', sandboxUserName as string]);\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);\n }\n const result = child_process.spawnSync('ps', ['-o', 'pid=,stat=,nlwp=,comm=', '-u', sandboxUserName as string], {\n env: MINIMAL_ENV,\n encoding: 'utf8',\n });\n // `ps` exits with 1 both when no process matched and on its own errors, which it reports on\n // stderr; anything else is a failure to list, which must count as a failure to verify.\n const stderr = result.stderr?.trim();\n if (result.error) return `cannot list processes: ${result.error.message}`;\n if (result.signal || (result.status !== 0 && (result.status !== 1 || stderr))) {\n return `cannot list processes: ${stderr || `ps exited with ${result.status ?? result.signal}`}`;\n }\n const survivors = result.stdout\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line && !/^\\d+\\s+Z\\S*\\s+1\\s/.test(line));\n if (survivors.length === 0) return undefined;\n if (attempt >= 10) return survivors.join(', ');\n }\n}\n\n/**\n * Let the sandbox user reopen the permissions of its own files under the given path, for\n * harness-side traversal/cleanup of trees where a sandboxed process restricted permissions\n * (some tools chmod their outputs regardless of umask).\n */\nexport function relaxPermissionsAsSandboxUser(targetPath: string): void {\n if (!sandboxUserName) return;\n runAsSandboxUser(['chmod', '-R', 'a+rwX', targetPath]);\n}\n\n/**\n * How long after a command's own deadline {@link startSandboxTimeoutWatchdog} force-kills the\n * sandbox user. Callers waiting for the watchdog to end a submission must wait at least this long.\n */\nexport const SANDBOX_WATCHDOG_GRACE_SECONDS = 5;\n\n/** Cancels a {@link startSandboxTimeoutWatchdog}; `fired` reports whether its deadline elapsed. */\nexport interface SandboxTimeoutWatchdog {\n cancel(): void;\n fired(): boolean;\n}\n\n/**\n * Start a harness-owned watchdog that force-kills every sandbox process after the given deadline.\n * A sandboxed submission can signal its own `timeout` supervisor (same UID), and a synchronous\n * spawn blocks the harness's event loop, so without this external deadline a submission could run\n * until the outer judge-server limit. The watchdog's `sh`/`sleep` run as the harness user, out of\n * the submission's reach; killing the watchdog's process group cancels it before it spawns sudo.\n *\n * ALWAYS cancel in a `finally`: the watchdog is detached and `unref`'d, so a leaked one keeps\n * running after the harness exits and would SIGKILL a later request's submission.\n */\nexport function startSandboxTimeoutWatchdog(timeoutSeconds: number): SandboxTimeoutWatchdog {\n if (!sandboxUserName) return { cancel: () => {}, fired: () => false };\n\n const deadlineSeconds = Math.ceil(timeoutSeconds) + SANDBOX_WATCHDOG_GRACE_SECONDS;\n const watchdog = child_process.spawn(\n '/bin/sh',\n ['-c', `${SLEEP_PATH} ${deadlineSeconds}; ${SUDO_PATH} -u \"$1\" pkill -KILL -u \"$1\"`, 'sh', sandboxUserName],\n { detached: true, stdio: 'ignore', env: MINIMAL_ENV }\n );\n // Without a listener, a spawn failure (e.g. the submission exhausted the PID cgroup) would be an\n // unhandled 'error' event that terminates the harness. Fail closed instead: callers check\n // `fired()`, and a watchdog that never started reports its deadline as elapsed.\n let spawnFailed = false;\n watchdog.on('error', () => {\n spawnFailed = true;\n });\n let exited = false;\n watchdog.on('exit', () => {\n exited = true;\n });\n watchdog.unref();\n\n const startTimeMilliseconds = Date.now();\n let cancelled = false;\n return {\n cancel: () => {\n cancelled = true;\n if (watchdog.pid === undefined || exited) return;\n try {\n process.kill(-watchdog.pid, 'SIGKILL');\n } catch {\n // The watchdog already fired and exited.\n }\n },\n // The `exit`/`error` events cannot be observed from a synchronous caller (they need the event\n // loop), so decide by elapsed time, which is what the watchdog itself waits on.\n fired: () =>\n spawnFailed ||\n watchdog.pid === undefined ||\n (!cancelled && Date.now() - startTimeMilliseconds >= deadlineSeconds * 1000),\n };\n}\n\n/**\n * Run a helper as the sandbox user with a minimal environment. The harness environment must not be\n * passed: sudoers forwards it verbatim, and after sudo drops privileges the helper's\n * `/proc/<pid>/environ` becomes readable by every other sandbox process.\n */\nfunction runAsSandboxUser(command: [string, ...string[]]): child_process.SpawnSyncReturns<Buffer> {\n return child_process.spawnSync(SUDO_PATH, ['-u', sandboxUserName as string, ...command], { env: MINIMAL_ENV });\n}\n\n/**\n * `fs.rm`-like removal with a fallback for trees holding sandbox-user-owned entries whose\n * permissions block deletion: let the sandbox user reopen its own files first, then retry. The\n * containing directory is relaxed as well — unlinking an entry needs write permission on its\n * parent, so a submission that chmods a directory it owns would otherwise make everything inside it\n * undeletable.\n */\nexport async function forceRemove(targetPath: string): Promise<void> {\n try {\n await fs.promises.rm(targetPath, { force: true, recursive: true });\n } catch (error) {\n if (!sandboxUserName) throw error;\n relaxPermissionsAsSandboxUser(targetPath);\n // Not recursive: only the containing directory's own write bit governs the unlink.\n runAsSandboxUser(['chmod', 'a+rwX', path.dirname(targetPath)]);\n await fs.promises.rm(targetPath, { force: true, recursive: true });\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sandboxUser.js","names":[],"sources":["../../src/helpers/sandboxUser.ts"],"sourcesContent":["import child_process from 'node:child_process';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\n/**\n * Name of the environment variable through which a judge server tells problem-utils to run\n * untrusted submitted programs as the given unprivileged OS user via sudo.\n *\n * The judge server sets this variable only when it runs the judge harness (e.g. `judge.ts`) as its\n * own trusted user, so that problem files (test cases and the harness itself) stay unreadable to\n * submissions while the submissions themselves run under the sandbox user. When the variable is\n * absent or empty (local development, course authoring, or an all-sandbox judge run), commands run\n * as the current user like before.\n *\n * Contract for the delegating judge server (delegation is Linux-only — sudo user separation and\n * `/home/<user>` homes are provisioned in the judge Docker image; never set this on macOS):\n * - The harness process environment is forwarded to sandboxed submissions (sudo runs with\n * `--preserve-env`), so it must not contain secrets beyond what submissions may see. The one\n * exception is the set glibc strips when executing the setuid `sudo` (secure-execution mode:\n * `LD_*`, `TMPDIR`, `LOCPATH`, `NLSPATH`, `TZDIR`, … — see ld.so(8)); no sudoers setting can\n * recover those, and only `LD_LIBRARY_PATH` is restored, by the wrapper below.\n * - sudoers must let the harness user run arbitrary commands as the sandbox user without a\n * password, pass the environment through, and keep sudo off a pseudo-terminal (a pty would merge\n * stderr into stdout and CRLF-mangle output when the harness happens to run from a terminal):\n * e.g. `Defaults:<harness> !env_reset, !env_delete, !env_check, !secure_path, !use_pty` plus\n * `<harness> ALL=(<sandbox>) NOPASSWD:SETENV: ALL`.\n * - Variables meant for the submitted program rather than for the harness must be passed under\n * {@link SANDBOX_ENV_PREFIX}; problem-utils strips that prefix when it builds a submission's\n * environment.\n * - The sandbox user's home directory must exist at `/home/<sandbox user>` and be writable. It is\n * shared across sequential requests, so the server is responsible for resetting whatever\n * cross-request persistence there matters to it.\n */\nexport const SANDBOX_USER_ENV_NAME = 'EXERCODE_SANDBOX_USER';\n\n// Absolute paths so a submission-influenced `PATH` cannot redirect binaries that the trusted\n// harness user executes (`sudo` is also setuid and must be the real one).\nconst SUDO_PATH = '/usr/bin/sudo';\nconst CHMOD_PATH = '/bin/chmod';\nconst SLEEP_PATH = '/bin/sleep';\n// Minimal environment for trusted helper processes; never forward the harness environment to them.\nconst MINIMAL_ENV = { PATH: '/usr/local/bin:/usr/bin:/bin' } as const;\n\n/**\n * Environment for helper processes the *trusted* harness user runs (`ps`, `xwininfo`, `Xvfb`, …).\n * The judge server overlays caller-supplied variables onto the harness environment, and a sandboxed\n * submission can create executables in its persistent home, so resolving these helpers through the\n * inherited `PATH` would hand the submission code execution as the harness user. Node resolves the\n * command through the child environment's `PATH`, so a fixed `PATH` here pins them to system paths.\n */\nexport function getTrustedHelperEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n // Without delegation the submission runs as this same user, so there is no boundary to defend and\n // pinning `PATH` would only break setups whose X11/`ps` binaries live elsewhere (e.g. /usr/games,\n // /snap/bin, Nix). Keep the inherited environment there, exactly as before delegation existed.\n if (!sandboxUserName) return { ...process.env, ...extraEnv };\n // `PATH` last: a caller passing a whole environment through would otherwise reinstate the\n // submission-influenced one this function exists to replace.\n return { ...extraEnv, PATH: MINIMAL_ENV.PATH };\n}\n\nexport const sandboxUserName = process.env[SANDBOX_USER_ENV_NAME] || undefined;\n\n// Fail fast on the catastrophic misconfiguration where the sandbox user is the harness's own user:\n// cleanup (`killSandboxUserProcesses`) would then SIGKILL the harness itself on the first run.\nif (sandboxUserName && sandboxUserName === os.userInfo().username) {\n throw new Error(\n `${SANDBOX_USER_ENV_NAME} must name a different OS user than the one running the harness (got \"${sandboxUserName}\"). Leave it unset to run everything as the current user.`\n );\n}\n\n/**\n * The deadline supervisor. Absolute under delegation: the wrapper's `exec \"$0\"` resolves it through\n * the submission's own `PATH` (which a judge server may set via {@link SANDBOX_ENV_PREFIX}), so a\n * bare name would let a submission replace the very process that enforces its time limit. The bare\n * name is kept without delegation, where `timeout` may live elsewhere (Homebrew coreutils).\n */\nexport const TIMEOUT_COMMAND = sandboxUserName ? '/usr/bin/timeout' : 'timeout';\n\n/**\n * Wrap a command so it runs as the sandbox user. `umask 0` makes every file the sandboxed process\n * creates world-writable, so the harness user can clean it up without privileges. The wrapper also\n * restores `LD_LIBRARY_PATH`, which ld.so strips across the setuid `sudo` exec. Put `timeout`\n * inside the wrapped command: the harness user cannot signal the root-owned `sudo` process, so an\n * outer timer alone could not stop a runaway submission.\n */\nexport function wrapCommandWithSandboxUser(command: readonly [string, ...string[]]): [string, ...string[]] {\n if (!sandboxUserName) return [...command];\n // Idempotent; see {@link isSandboxWrappedCommand}.\n if (isSandboxWrappedCommand(command)) return [...command];\n return [...buildSandboxWrapperPrefix(sandboxUserName), ...command];\n}\n\nconst SANDBOX_WRAPPER_SCRIPT =\n 'umask 0; if [ -n \"$SANDBOX_LD_LIBRARY_PATH\" ]; then export LD_LIBRARY_PATH=\"$SANDBOX_LD_LIBRARY_PATH\"; fi; exec \"$0\" \"$@\"';\n\nfunction buildSandboxWrapperPrefix(user: string): [string, ...string[]] {\n return [SUDO_PATH, '--preserve-env', '-u', user, '--', 'sh', '-c', SANDBOX_WRAPPER_SCRIPT];\n}\n\n/**\n * Whether the command was already wrapped by {@link wrapCommandWithSandboxUser}. The presets hand\n * custom runners a wrapped command, and a runner may forward it to another helper that wraps too;\n * a nested wrapper's inner `sudo` would run AS the sandbox user, which sudoers does not authorize,\n * so every test case of such a problem would fail only under delegation.\n *\n * Matches the wrapper prefix exactly rather than looking for `sudo` anywhere: a submission-derived\n * command could otherwise carry a literal `/usr/bin/sudo` argument and skip wrapping entirely,\n * which would run the submission as the trusted harness user.\n */\nexport function isSandboxWrappedCommand(command: readonly string[]): boolean {\n if (!sandboxUserName) return false;\n const prefix = buildSandboxWrapperPrefix(sandboxUserName);\n return prefix.every((argument, index) => command[index] === argument);\n}\n\n/**\n * Insert `innerPrefix` (e.g. a `time` measurement prefix) so that it runs INSIDE the sandbox\n * wrapper when `command` is wrapped, and simply in front otherwise. Prefixing a wrapped command\n * from the outside would run `innerPrefix` as the trusted harness user while its arguments (such\n * as an output path) point into a sandbox-writable directory, where a submission can plant a\n * symlink and have the harness truncate an arbitrary file it owns.\n */\nexport function prependInsideSandboxWrapper(\n command: readonly [string, ...string[]],\n innerPrefix: readonly string[]\n): [string, ...string[]] {\n if (!isSandboxWrappedCommand(command)) return [...innerPrefix, ...command] as [string, ...string[]];\n const prefixLength = buildSandboxWrapperPrefix(sandboxUserName as string).length;\n return [...command.slice(0, prefixLength), ...innerPrefix, ...command.slice(prefixLength)] as [string, ...string[]];\n}\n\n/**\n * Environment overrides for sandboxed processes: a writable home and the `LD_LIBRARY_PATH`\n * smuggled past the setuid `sudo` exec (see {@link wrapCommandWithSandboxUser}). Pass the\n * environment the command will actually run with so a caller-supplied `LD_LIBRARY_PATH` survives\n * the exec; the harness's own value is only the fallback.\n */\nexport function getSandboxUserEnvOverrides(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n if (!sandboxUserName) return {};\n const sourceEnv = env ?? process.env;\n const submissionOnlyEnv = unwrapSubmissionOnlyEnv(sourceEnv);\n // A submission-only `LD_LIBRARY_PATH` must be the one smuggled past the exec, not the harness's.\n const ldLibraryPath = submissionOnlyEnv.LD_LIBRARY_PATH ?? sourceEnv.LD_LIBRARY_PATH ?? process.env.LD_LIBRARY_PATH;\n return {\n HOME: `/home/${sandboxUserName}`,\n ...submissionOnlyEnv,\n // Last: the wrapper reads this one, so a caller must not be able to overwrite it.\n ...(ldLibraryPath && { SANDBOX_LD_LIBRARY_PATH: ldLibraryPath }),\n };\n}\n\n/**\n * Prefix under which a delegating judge server passes variables that belong to the submitted\n * program alone. Applying a request's variables to the harness itself would let a submission point\n * e.g. `PATH` or `NODE_OPTIONS` at its own files and get code executed as the trusted harness user,\n * so the server prefixes them and problem-utils strips the prefix back off here, where the\n * environment of a sandboxed submission is built.\n */\nexport const SANDBOX_ENV_PREFIX = 'SANDBOX_ENV_';\n\nfunction unwrapSubmissionOnlyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n const unwrapped: NodeJS.ProcessEnv = {};\n for (const [name, value] of Object.entries(env)) {\n if (!name.startsWith(SANDBOX_ENV_PREFIX)) continue;\n const unwrappedName = name.slice(SANDBOX_ENV_PREFIX.length);\n if (unwrappedName) unwrapped[unwrappedName] = value;\n }\n return unwrapped;\n}\n\n/**\n * Make harness-user-created files under the given path readable, and directories writable, for the\n * sandbox user, so sandboxed programs can read their sources and create outputs next to them.\n */\nexport function makeAccessibleToSandboxUser(targetPath: string): void {\n if (!sandboxUserName) return;\n // Files owned by the sandbox user fail to chmod but are already world-writable (umask 0).\n child_process.spawnSync(CHMOD_PATH, ['-R', 'a+rwX', targetPath], { env: MINIMAL_ENV });\n}\n\n/**\n * Let the sandbox user traverse (but not modify) every directory from `rootPath` down to\n * `directoryPath`, e.g. the synthesized ancestors of a temporary copy.\n */\nexport function makeTraversableBySandboxUser(rootPath: string, directoryPath: string): void {\n if (!sandboxUserName) return;\n const directories: string[] = [];\n for (let current = directoryPath; ; current = path.dirname(current)) {\n directories.push(current);\n if (current === rootPath || path.dirname(current) === current) break;\n }\n child_process.spawnSync(CHMOD_PATH, ['a+X', ...directories], { env: MINIMAL_ENV });\n}\n\n/**\n * Kill the sandbox user's processes with the given signals. The harness user cannot signal another\n * user's processes (nor the root-owned `sudo` wrapper), so this goes through sudo. Killing every\n * sandbox process is safe because the judge server handles one request at a time. The default\n * sends SIGTERM immediately followed by SIGKILL (final cleanup); callers that want a grace period\n * send `['TERM']`, wait, and then send `['KILL']`.\n *\n * Fails closed: a `['TERM']`-only sweep throws when its `sudo` could not be spawned (e.g. a\n * submission exhausted the PID cgroup); a sweep including SIGKILL throws when a sandbox process is\n * still alive, or cannot be listed, afterwards. Reporting success would leave that process running\n * next to the next request on this instance.\n */\nexport function killSandboxUserProcesses(signals: readonly ('TERM' | 'KILL')[] = ['TERM', 'KILL']): void {\n if (!sandboxUserName) return;\n // One direct call per signal instead of one `sh -c 'pkill ...; pkill ...'`: the wrapping shell\n // would run as the sandbox user too, so the first pkill would kill it before the second ran.\n // Exit statuses are not inspected: `sudo` exits with 1 both when `pkill` matched nothing and on\n // its own failures, and a sweep can itself be killed by a concurrent watchdog sweep\n // ({@link startSandboxTimeoutWatchdog}'s `pkill -KILL` matches it). What matters is the outcome.\n const spawnErrors: string[] = [];\n for (const signal of signals) {\n const result = runAsSandboxUser(['pkill', `-${signal}`, '-u', sandboxUserName]);\n if (result.error) spawnErrors.push(`pkill -${signal}: ${result.error.message}`);\n }\n if (!signals.includes('KILL')) {\n // Survivors are expected during the caller's grace period, so only a sweep that never started\n // is a failure here.\n if (spawnErrors.length > 0) {\n throw new Error(`failed to signal ${sandboxUserName} processes: ${spawnErrors.join('; ')}`);\n }\n return;\n }\n const survivors = findSurvivingSandboxUserProcesses();\n if (survivors) throw new Error(`failed to terminate ${sandboxUserName} processes: ${survivors}`);\n}\n\n/**\n * Lists the sandbox user's live processes as the harness user (listing needs no privilege), or\n * `undefined` when none remain. SIGKILL is re-sent between attempts: a child forked after `pkill`\n * scanned `/proc` was never signalled, and a process in uninterruptible sleep dies only once its\n * I/O completes. Just-killed processes linger as zombies until reaped; a zombie row is ignored only\n * when it has a single thread, because a thread-group leader that exited via `pthread_exit` shows\n * as a zombie while its other threads keep running.\n */\nfunction findSurvivingSandboxUserProcesses(): string | undefined {\n for (let attempt = 0; ; attempt++) {\n if (attempt > 0) {\n runAsSandboxUser(['pkill', '-KILL', '-u', sandboxUserName as string]);\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);\n }\n const result = child_process.spawnSync('ps', ['-o', 'pid=,stat=,nlwp=,comm=', '-u', sandboxUserName as string], {\n env: MINIMAL_ENV,\n encoding: 'utf8',\n });\n // `ps` exits with 1 both when no process matched and on its own errors, which it reports on\n // stderr; anything else is a failure to list, which must count as a failure to verify.\n const stderr = result.stderr?.trim();\n if (result.error) return `cannot list processes: ${result.error.message}`;\n if (result.signal || (result.status !== 0 && (result.status !== 1 || stderr))) {\n return `cannot list processes: ${stderr || `ps exited with ${result.status ?? result.signal}`}`;\n }\n const survivors = result.stdout\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line && !/^\\d+\\s+Z\\S*\\s+1\\s/.test(line));\n if (survivors.length === 0) return undefined;\n if (attempt >= 10) return survivors.join(', ');\n }\n}\n\n/**\n * Let the sandbox user reopen the permissions of its own files under the given path, for\n * harness-side traversal/cleanup of trees where a sandboxed process restricted permissions\n * (some tools chmod their outputs regardless of umask).\n */\nexport function relaxPermissionsAsSandboxUser(targetPath: string): void {\n if (!sandboxUserName) return;\n runAsSandboxUser(['chmod', '-R', 'a+rwX', targetPath]);\n}\n\n/**\n * How long after a command's own deadline {@link startSandboxTimeoutWatchdog} force-kills the\n * sandbox user. Callers waiting for the watchdog to end a submission must wait at least this long.\n */\nexport const SANDBOX_WATCHDOG_GRACE_SECONDS = 5;\n\n/** Cancels a {@link startSandboxTimeoutWatchdog}; `fired` reports whether its deadline elapsed. */\nexport interface SandboxTimeoutWatchdog {\n cancel(): void;\n fired(): boolean;\n}\n\n/**\n * Start a harness-owned watchdog that force-kills every sandbox process after the given deadline.\n * A sandboxed submission can signal its own `timeout` supervisor (same UID), and a synchronous\n * spawn blocks the harness's event loop, so without this external deadline a submission could run\n * until the outer judge-server limit. The watchdog's `sh`/`sleep` run as the harness user, out of\n * the submission's reach; killing the watchdog's process group cancels it before it spawns sudo.\n *\n * ALWAYS cancel in a `finally`: the watchdog is detached and `unref`'d, so a leaked one keeps\n * running after the harness exits and would SIGKILL a later request's submission.\n */\nexport function startSandboxTimeoutWatchdog(timeoutSeconds: number): SandboxTimeoutWatchdog {\n if (!sandboxUserName) return { cancel: () => {}, fired: () => false };\n\n const deadlineSeconds = Math.ceil(timeoutSeconds) + SANDBOX_WATCHDOG_GRACE_SECONDS;\n const watchdog = child_process.spawn(\n '/bin/sh',\n ['-c', `${SLEEP_PATH} ${deadlineSeconds}; ${SUDO_PATH} -u \"$1\" pkill -KILL -u \"$1\"`, 'sh', sandboxUserName],\n { detached: true, stdio: 'ignore', env: MINIMAL_ENV }\n );\n // Without a listener, a spawn failure (e.g. the submission exhausted the PID cgroup) would be an\n // unhandled 'error' event that terminates the harness. Fail closed instead: callers check\n // `fired()`, and a watchdog that never started reports its deadline as elapsed.\n let spawnFailed = false;\n watchdog.on('error', () => {\n spawnFailed = true;\n });\n let exited = false;\n watchdog.on('exit', () => {\n exited = true;\n });\n watchdog.unref();\n\n const startTimeMilliseconds = Date.now();\n let cancelled = false;\n return {\n cancel: () => {\n cancelled = true;\n if (watchdog.pid === undefined || exited) return;\n try {\n process.kill(-watchdog.pid, 'SIGKILL');\n } catch {\n // The watchdog already fired and exited.\n }\n },\n // The `exit`/`error` events cannot be observed from a synchronous caller (they need the event\n // loop), so decide by elapsed time, which is what the watchdog itself waits on.\n fired: () =>\n spawnFailed ||\n watchdog.pid === undefined ||\n (!cancelled && Date.now() - startTimeMilliseconds >= deadlineSeconds * 1000),\n };\n}\n\n/**\n * Run a helper as the sandbox user with a minimal environment. The harness environment must not be\n * passed: sudoers forwards it verbatim, and after sudo drops privileges the helper's\n * `/proc/<pid>/environ` becomes readable by every other sandbox process.\n */\nfunction runAsSandboxUser(command: [string, ...string[]]): child_process.SpawnSyncReturns<Buffer> {\n return child_process.spawnSync(SUDO_PATH, ['-u', sandboxUserName as string, ...command], { env: MINIMAL_ENV });\n}\n\n/**\n * `fs.rm`-like removal with a fallback for trees holding sandbox-user-owned entries whose\n * permissions block deletion: let the sandbox user reopen its own files first, then retry. The\n * containing directory is relaxed as well — unlinking an entry needs write permission on its\n * parent, so a submission that chmods a directory it owns would otherwise make everything inside it\n * undeletable.\n */\nexport async function forceRemove(targetPath: string): Promise<void> {\n try {\n await fs.promises.rm(targetPath, { force: true, recursive: true });\n } catch (error) {\n if (!sandboxUserName) throw error;\n relaxPermissionsAsSandboxUser(targetPath);\n // Not recursive: only the containing directory's own write bit governs the unlink.\n runAsSandboxUser(['chmod', 'a+rwX', path.dirname(targetPath)]);\n await fs.promises.rm(targetPath, { force: true, recursive: true });\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("./sandboxUser.cjs"),n=require("./safeFs.cjs");let r=require("node:
|
|
1
|
+
"use strict";const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("./sandboxUser.cjs"),n=require("./safeFs.cjs");let r=require("node:fs");r=e.__toESM(r,1);let i=require("node:path");i=e.__toESM(i,1);let a=require("node:http");a=e.__toESM(a,1);function o(e){let t=a.default.createServer((t,n)=>{let a=new URL(t.url??`/`,`http://127.0.0.1`).pathname;a===`/`&&(a=`/index.html`);let o=a.endsWith(`/`)?i.default.join(a,`index.html`):a,s=i.default.join(e,o);if(!c(e,s)){n.statusCode=404,n.end();return}if(r.default.existsSync(s))try{let e=r.default.readFileSync(s);n.writeHead(200,{"Access-Control-Allow-Origin":`*`,Pragma:`no-cache`,"Cache-Control":`no-cache`,"Content-Type":l(o)}),n.end(e)}catch{n.statusCode=500,n.end()}else n.statusCode=404,n.end()});t.listen();let n=t.address();if(!n)throw Error(`server has been unexpectedly closed`);return{[Symbol.asyncDispose]:async()=>{t.closeAllConnections(),await new Promise(e=>{t.close(()=>{e()})})},url:typeof n==`string`?n:`http://127.0.0.1:${n.port}`,port:typeof n==`object`?n.port:void 0}}const s={".css":`text/css`,".html":`text/html`,".js":`text/javascript`,".gif":`image/gif`,".jpg":`image/jpeg`,".png":`image/png`,".svg":`image/svg+xml`};function c(e,i){if(!t.sandboxUserName)return!0;try{return n.isContainedPath(r.default.realpathSync(e),r.default.realpathSync(i))}catch{return!r.default.existsSync(i)}}function l(e){for(let[t,n]of Object.entries(s))if(e.endsWith(t))return n;return`text/plain`}exports.startHttpServer=o;
|
|
2
2
|
//# sourceMappingURL=startHttpServer.cjs.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{sandboxUserName as e}from"./sandboxUser.js";import{isContainedPath as t}from"./safeFs.js";import n from"node:
|
|
1
|
+
import{sandboxUserName as e}from"./sandboxUser.js";import{isContainedPath as t}from"./safeFs.js";import n from"node:fs";import r from"node:path";import i from"node:http";function a(e){let t=i.createServer((t,i)=>{let a=new URL(t.url??`/`,`http://127.0.0.1`).pathname;a===`/`&&(a=`/index.html`);let o=a.endsWith(`/`)?r.join(a,`index.html`):a,l=r.join(e,o);if(!s(e,l)){i.statusCode=404,i.end();return}if(n.existsSync(l))try{let e=n.readFileSync(l);i.writeHead(200,{"Access-Control-Allow-Origin":`*`,Pragma:`no-cache`,"Cache-Control":`no-cache`,"Content-Type":c(o)}),i.end(e)}catch{i.statusCode=500,i.end()}else i.statusCode=404,i.end()});t.listen();let a=t.address();if(!a)throw Error(`server has been unexpectedly closed`);return{[Symbol.asyncDispose]:async()=>{t.closeAllConnections(),await new Promise(e=>{t.close(()=>{e()})})},url:typeof a==`string`?a:`http://127.0.0.1:${a.port}`,port:typeof a==`object`?a.port:void 0}}const o={".css":`text/css`,".html":`text/html`,".js":`text/javascript`,".gif":`image/gif`,".jpg":`image/jpeg`,".png":`image/png`,".svg":`image/svg+xml`};function s(r,i){if(!e)return!0;try{return t(n.realpathSync(r),n.realpathSync(i))}catch{return!n.existsSync(i)}}function c(e){for(let[t,n]of Object.entries(o))if(e.endsWith(t))return n;return`text/plain`}export{a as startHttpServer};
|
|
2
2
|
//# sourceMappingURL=startHttpServer.js.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";const e=require("../_virtual/_rolldown/runtime.cjs");let t=require("node:fs");t=e.__toESM(t,1);let n=require("node:path");n=e.__toESM(n,1);let r=require("node:child_process");r=e.__toESM(r,1);let i=require("node:os");i=e.__toESM(i,1);async function a(e,r={}){let a=await t.default.promises.mkdtemp(n.default.join(i.default.tmpdir(),`problem-utils-isolation_`));r.onTempRootCreated?.(a);try{let r=n.default.resolve(e),i=n.default.join(a,f(r));return await t.default.promises.mkdir(n.default.dirname(i),{recursive:!0}),await t.default.promises.cp(r,i,{recursive:!0,filter:l,verbatimSymlinks:!0}),await d(a,r),{tempRoot:a,copiedProblemDir:i}}catch(e){throw await o(a),e}}async function o(e){try{return await t.default.promises.rm(e,{recursive:!0,force:!0}),!0}catch{c(e);try{return await t.default.promises.rm(e,{recursive:!0,force:!0}),!0}catch{return!1}}}function s(e){try{return t.default.rmSync(e,{recursive:!0,force:!0}),!0}catch{c(e);try{return t.default.rmSync(e,{recursive:!0,force:!0}),!0}catch{return!1}}}function c(e){process.platform!==`win32`&&r.default.spawnSync(`chmod`,[`-R`,`u+rwX`,e])}async function l(e){if(!u(e))return!1;if((await t.default.promises.lstat(e)).isSymbolicLink()&&n.default.isAbsolute(await t.default.promises.readlink(e)))throw Error(`${e} is an absolute symlink, which would escape the temporary copy; use a relative symlink`);return!0}function u(e){let t=n.default.basename(e);return t!==`node_modules`&&t!==`.git`}async function d(e,r){let i=n.default.resolve(r);for(;;){let r=n.default.join(i,`node_modules`);if(t.default.existsSync(r)){let a=n.default.join(e,f(i),`node_modules`);try{await t.default.promises.symlink(r,a,process.platform===`win32`?`junction`:`dir`)}catch{}}let a=n.default.dirname(i);if(a===i)break;i=a}}function f(e){let{root:t}=n.default.parse(e);return n.default.relative(t,e)}exports.copyProblemDirToTemporaryRoot=a,exports.forciblyRemoveDirectory=o,exports.forciblyRemoveDirectorySync=s;
|
|
2
|
+
//# sourceMappingURL=temporaryProblemDirCopy.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"temporaryProblemDirCopy.cjs","names":["fs","path","os"],"sources":["../../src/helpers/temporaryProblemDirCopy.ts"],"sourcesContent":["import child_process from 'node:child_process';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\n/**\n * Copy a problem directory into a temporary root, symlinking every ancestor `node_modules` so the\n * copied harness still resolves its imports. Callers must remove the returned `tempRoot`.\n */\nexport async function copyProblemDirToTemporaryRoot(\n problemDir: string,\n options: {\n /** Called as soon as the root exists, so a caller's signal cleanup can cover the copy phase too. */\n onTempRootCreated?: (tempRoot: string) => void;\n } = {}\n): Promise<{ tempRoot: string; copiedProblemDir: string }> {\n const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'problem-utils-isolation_'));\n options.onTempRootCreated?.(tempRoot);\n try {\n const absoluteProblemDir = path.resolve(problemDir);\n const copiedProblemDir = path.join(tempRoot, toTempRelativePath(absoluteProblemDir));\n await fs.promises.mkdir(path.dirname(copiedProblemDir), { recursive: true });\n await fs.promises.cp(absoluteProblemDir, copiedProblemDir, {\n recursive: true,\n filter: rejectAbsoluteSymlinks,\n // Keep relative symlinks relative: the default rewrites them to absolute paths into the\n // source tree, so judging the copy could write through them into the checked repository.\n verbatimSymlinks: true,\n });\n await symlinkAllAncestorNodeModules(tempRoot, absoluteProblemDir);\n return { tempRoot, copiedProblemDir };\n } catch (error) {\n await forciblyRemoveDirectory(tempRoot);\n throw error;\n }\n}\n\n/**\n * Remove a temporary directory even when judged code left permission-locked entries in it (e.g. a\n * mode-000 directory makes a plain `fs.rm` fail with EACCES). Returns whether removal succeeded.\n */\nexport async function forciblyRemoveDirectory(dir: string): Promise<boolean> {\n try {\n await fs.promises.rm(dir, { recursive: true, force: true });\n return true;\n } catch {\n unlockPermissions(dir);\n try {\n await fs.promises.rm(dir, { recursive: true, force: true });\n return true;\n } catch {\n return false;\n }\n }\n}\n\n/** Synchronous variant of {@link forciblyRemoveDirectory} for signal handlers. */\nexport function forciblyRemoveDirectorySync(dir: string): boolean {\n try {\n fs.rmSync(dir, { recursive: true, force: true });\n return true;\n } catch {\n unlockPermissions(dir);\n try {\n fs.rmSync(dir, { recursive: true, force: true });\n return true;\n } catch {\n return false;\n }\n }\n}\n\n// chmod does not exist on Windows, where POSIX-mode locks cannot occur anyway. spawnSync reports\n// spawn failures via its return value instead of throwing, so no try/catch is needed.\nfunction unlockPermissions(dir: string): void {\n if (process.platform !== 'win32') child_process.spawnSync('chmod', ['-R', 'u+rwX', dir]);\n}\n\n// An absolute symlink is copied verbatim, so judging the copy could write through it into the\n// original tree; reject it instead of silently breaking the isolation guarantee.\nasync function rejectAbsoluteSymlinks(src: string): Promise<boolean> {\n if (!isCopiedProblemPath(src)) return false;\n const stats = await fs.promises.lstat(src);\n if (stats.isSymbolicLink() && path.isAbsolute(await fs.promises.readlink(src))) {\n throw new Error(`${src} is an absolute symlink, which would escape the temporary copy; use a relative symlink`);\n }\n return true;\n}\n\nfunction isCopiedProblemPath(src: string): boolean {\n const name = path.basename(src);\n return name !== 'node_modules' && name !== '.git';\n}\n\nasync function symlinkAllAncestorNodeModules(tempRoot: string, problemDir: string): Promise<void> {\n let currentDir = path.resolve(problemDir);\n while (true) {\n const nodeModulesPath = path.join(currentDir, 'node_modules');\n if (fs.existsSync(nodeModulesPath)) {\n const targetSymlinkPath = path.join(tempRoot, toTempRelativePath(currentDir), 'node_modules');\n try {\n await fs.promises.symlink(\n nodeModulesPath,\n targetSymlinkPath,\n process.platform === 'win32' ? 'junction' : 'dir'\n );\n } catch {\n // Package resolution is best-effort; the isolation check still reports a clear spawn failure if imports break.\n }\n }\n\n const parentDir = path.dirname(currentDir);\n if (parentDir === currentDir) break;\n currentDir = parentDir;\n }\n}\n\nfunction toTempRelativePath(absolutePath: string): string {\n const { root } = path.parse(absolutePath);\n return path.relative(root, absolutePath);\n}\n"],"mappings":"uPASA,eAAsB,EACpB,EACA,EAGI,CAAC,EACoD,CACzD,IAAM,EAAW,MAAMA,EAAAA,QAAG,SAAS,QAAQC,EAAAA,QAAK,KAAKC,EAAAA,QAAG,OAAO,EAAG,0BAA0B,CAAC,EAC7F,EAAQ,oBAAoB,CAAQ,EACpC,GAAI,CACF,IAAM,EAAqBD,EAAAA,QAAK,QAAQ,CAAU,EAC5C,EAAmBA,EAAAA,QAAK,KAAK,EAAU,EAAmB,CAAkB,CAAC,EAUnF,OATA,MAAMD,EAAAA,QAAG,SAAS,MAAMC,EAAAA,QAAK,QAAQ,CAAgB,EAAG,CAAE,UAAW,EAAK,CAAC,EAC3E,MAAMD,EAAAA,QAAG,SAAS,GAAG,EAAoB,EAAkB,CACzD,UAAW,GACX,OAAQ,EAGR,iBAAkB,EACpB,CAAC,EACD,MAAM,EAA8B,EAAU,CAAkB,EACzD,CAAE,WAAU,kBAAiB,CACtC,OAAS,EAAO,CAEd,MADA,MAAM,EAAwB,CAAQ,EAChC,CACR,CACF,CAMA,eAAsB,EAAwB,EAA+B,CAC3E,GAAI,CAEF,OADA,MAAMA,EAAAA,QAAG,SAAS,GAAG,EAAK,CAAE,UAAW,GAAM,MAAO,EAAK,CAAC,EACnD,EACT,MAAQ,CACN,EAAkB,CAAG,EACrB,GAAI,CAEF,OADA,MAAMA,EAAAA,QAAG,SAAS,GAAG,EAAK,CAAE,UAAW,GAAM,MAAO,EAAK,CAAC,EACnD,EACT,MAAQ,CACN,MAAO,EACT,CACF,CACF,CAGA,SAAgB,EAA4B,EAAsB,CAChE,GAAI,CAEF,OADA,EAAA,QAAG,OAAO,EAAK,CAAE,UAAW,GAAM,MAAO,EAAK,CAAC,EACxC,EACT,MAAQ,CACN,EAAkB,CAAG,EACrB,GAAI,CAEF,OADA,EAAA,QAAG,OAAO,EAAK,CAAE,UAAW,GAAM,MAAO,EAAK,CAAC,EACxC,EACT,MAAQ,CACN,MAAO,EACT,CACF,CACF,CAIA,SAAS,EAAkB,EAAmB,CACxC,QAAQ,WAAa,SAAS,EAAA,QAAc,UAAU,QAAS,CAAC,KAAM,QAAS,CAAG,CAAC,CACzF,CAIA,eAAe,EAAuB,EAA+B,CACnE,GAAI,CAAC,EAAoB,CAAG,EAAG,MAAO,GAEtC,IAAI,MADgBA,EAAAA,QAAG,SAAS,MAAM,CAAG,EAAA,CAC/B,eAAe,GAAKC,EAAAA,QAAK,WAAW,MAAMD,EAAAA,QAAG,SAAS,SAAS,CAAG,CAAC,EAC3E,MAAU,MAAM,GAAG,EAAI,uFAAuF,EAEhH,MAAO,EACT,CAEA,SAAS,EAAoB,EAAsB,CACjD,IAAM,EAAOC,EAAAA,QAAK,SAAS,CAAG,EAC9B,OAAO,IAAS,gBAAkB,IAAS,MAC7C,CAEA,eAAe,EAA8B,EAAkB,EAAmC,CAChG,IAAI,EAAaA,EAAAA,QAAK,QAAQ,CAAU,EACxC,OAAa,CACX,IAAM,EAAkBA,EAAAA,QAAK,KAAK,EAAY,cAAc,EAC5D,GAAID,EAAAA,QAAG,WAAW,CAAe,EAAG,CAClC,IAAM,EAAoBC,EAAAA,QAAK,KAAK,EAAU,EAAmB,CAAU,EAAG,cAAc,EAC5F,GAAI,CACF,MAAMD,EAAAA,QAAG,SAAS,QAChB,EACA,EACA,QAAQ,WAAa,QAAU,WAAa,KAC9C,CACF,MAAQ,CAER,CACF,CAEA,IAAM,EAAYC,EAAAA,QAAK,QAAQ,CAAU,EACzC,GAAI,IAAc,EAAY,MAC9B,EAAa,CACf,CACF,CAEA,SAAS,EAAmB,EAA8B,CACxD,GAAM,CAAE,QAASA,EAAAA,QAAK,MAAM,CAAY,EACxC,OAAOA,EAAAA,QAAK,SAAS,EAAM,CAAY,CACzC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy a problem directory into a temporary root, symlinking every ancestor `node_modules` so the
|
|
3
|
+
* copied harness still resolves its imports. Callers must remove the returned `tempRoot`.
|
|
4
|
+
*/
|
|
5
|
+
export declare function copyProblemDirToTemporaryRoot(problemDir: string, options?: {
|
|
6
|
+
/** Called as soon as the root exists, so a caller's signal cleanup can cover the copy phase too. */
|
|
7
|
+
onTempRootCreated?: (tempRoot: string) => void;
|
|
8
|
+
}): Promise<{
|
|
9
|
+
tempRoot: string;
|
|
10
|
+
copiedProblemDir: string;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* Remove a temporary directory even when judged code left permission-locked entries in it (e.g. a
|
|
14
|
+
* mode-000 directory makes a plain `fs.rm` fail with EACCES). Returns whether removal succeeded.
|
|
15
|
+
*/
|
|
16
|
+
export declare function forciblyRemoveDirectory(dir: string): Promise<boolean>;
|
|
17
|
+
/** Synchronous variant of {@link forciblyRemoveDirectory} for signal handlers. */
|
|
18
|
+
export declare function forciblyRemoveDirectorySync(dir: string): boolean;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import e from"node:fs";import t from"node:path";import n from"node:child_process";import r from"node:os";async function i(n,i={}){let o=await e.promises.mkdtemp(t.join(r.tmpdir(),`problem-utils-isolation_`));i.onTempRootCreated?.(o);try{let r=t.resolve(n),i=t.join(o,d(r));return await e.promises.mkdir(t.dirname(i),{recursive:!0}),await e.promises.cp(r,i,{recursive:!0,filter:c,verbatimSymlinks:!0}),await u(o,r),{tempRoot:o,copiedProblemDir:i}}catch(e){throw await a(o),e}}async function a(t){try{return await e.promises.rm(t,{recursive:!0,force:!0}),!0}catch{s(t);try{return await e.promises.rm(t,{recursive:!0,force:!0}),!0}catch{return!1}}}function o(t){try{return e.rmSync(t,{recursive:!0,force:!0}),!0}catch{s(t);try{return e.rmSync(t,{recursive:!0,force:!0}),!0}catch{return!1}}}function s(e){process.platform!==`win32`&&n.spawnSync(`chmod`,[`-R`,`u+rwX`,e])}async function c(n){if(!l(n))return!1;if((await e.promises.lstat(n)).isSymbolicLink()&&t.isAbsolute(await e.promises.readlink(n)))throw Error(`${n} is an absolute symlink, which would escape the temporary copy; use a relative symlink`);return!0}function l(e){let n=t.basename(e);return n!==`node_modules`&&n!==`.git`}async function u(n,r){let i=t.resolve(r);for(;;){let r=t.join(i,`node_modules`);if(e.existsSync(r)){let a=t.join(n,d(i),`node_modules`);try{await e.promises.symlink(r,a,process.platform===`win32`?`junction`:`dir`)}catch{}}let a=t.dirname(i);if(a===i)break;i=a}}function d(e){let{root:n}=t.parse(e);return t.relative(n,e)}export{i as copyProblemDirToTemporaryRoot,a as forciblyRemoveDirectory,o as forciblyRemoveDirectorySync};
|
|
2
|
+
//# sourceMappingURL=temporaryProblemDirCopy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"temporaryProblemDirCopy.js","names":[],"sources":["../../src/helpers/temporaryProblemDirCopy.ts"],"sourcesContent":["import child_process from 'node:child_process';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\n/**\n * Copy a problem directory into a temporary root, symlinking every ancestor `node_modules` so the\n * copied harness still resolves its imports. Callers must remove the returned `tempRoot`.\n */\nexport async function copyProblemDirToTemporaryRoot(\n problemDir: string,\n options: {\n /** Called as soon as the root exists, so a caller's signal cleanup can cover the copy phase too. */\n onTempRootCreated?: (tempRoot: string) => void;\n } = {}\n): Promise<{ tempRoot: string; copiedProblemDir: string }> {\n const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'problem-utils-isolation_'));\n options.onTempRootCreated?.(tempRoot);\n try {\n const absoluteProblemDir = path.resolve(problemDir);\n const copiedProblemDir = path.join(tempRoot, toTempRelativePath(absoluteProblemDir));\n await fs.promises.mkdir(path.dirname(copiedProblemDir), { recursive: true });\n await fs.promises.cp(absoluteProblemDir, copiedProblemDir, {\n recursive: true,\n filter: rejectAbsoluteSymlinks,\n // Keep relative symlinks relative: the default rewrites them to absolute paths into the\n // source tree, so judging the copy could write through them into the checked repository.\n verbatimSymlinks: true,\n });\n await symlinkAllAncestorNodeModules(tempRoot, absoluteProblemDir);\n return { tempRoot, copiedProblemDir };\n } catch (error) {\n await forciblyRemoveDirectory(tempRoot);\n throw error;\n }\n}\n\n/**\n * Remove a temporary directory even when judged code left permission-locked entries in it (e.g. a\n * mode-000 directory makes a plain `fs.rm` fail with EACCES). Returns whether removal succeeded.\n */\nexport async function forciblyRemoveDirectory(dir: string): Promise<boolean> {\n try {\n await fs.promises.rm(dir, { recursive: true, force: true });\n return true;\n } catch {\n unlockPermissions(dir);\n try {\n await fs.promises.rm(dir, { recursive: true, force: true });\n return true;\n } catch {\n return false;\n }\n }\n}\n\n/** Synchronous variant of {@link forciblyRemoveDirectory} for signal handlers. */\nexport function forciblyRemoveDirectorySync(dir: string): boolean {\n try {\n fs.rmSync(dir, { recursive: true, force: true });\n return true;\n } catch {\n unlockPermissions(dir);\n try {\n fs.rmSync(dir, { recursive: true, force: true });\n return true;\n } catch {\n return false;\n }\n }\n}\n\n// chmod does not exist on Windows, where POSIX-mode locks cannot occur anyway. spawnSync reports\n// spawn failures via its return value instead of throwing, so no try/catch is needed.\nfunction unlockPermissions(dir: string): void {\n if (process.platform !== 'win32') child_process.spawnSync('chmod', ['-R', 'u+rwX', dir]);\n}\n\n// An absolute symlink is copied verbatim, so judging the copy could write through it into the\n// original tree; reject it instead of silently breaking the isolation guarantee.\nasync function rejectAbsoluteSymlinks(src: string): Promise<boolean> {\n if (!isCopiedProblemPath(src)) return false;\n const stats = await fs.promises.lstat(src);\n if (stats.isSymbolicLink() && path.isAbsolute(await fs.promises.readlink(src))) {\n throw new Error(`${src} is an absolute symlink, which would escape the temporary copy; use a relative symlink`);\n }\n return true;\n}\n\nfunction isCopiedProblemPath(src: string): boolean {\n const name = path.basename(src);\n return name !== 'node_modules' && name !== '.git';\n}\n\nasync function symlinkAllAncestorNodeModules(tempRoot: string, problemDir: string): Promise<void> {\n let currentDir = path.resolve(problemDir);\n while (true) {\n const nodeModulesPath = path.join(currentDir, 'node_modules');\n if (fs.existsSync(nodeModulesPath)) {\n const targetSymlinkPath = path.join(tempRoot, toTempRelativePath(currentDir), 'node_modules');\n try {\n await fs.promises.symlink(\n nodeModulesPath,\n targetSymlinkPath,\n process.platform === 'win32' ? 'junction' : 'dir'\n );\n } catch {\n // Package resolution is best-effort; the isolation check still reports a clear spawn failure if imports break.\n }\n }\n\n const parentDir = path.dirname(currentDir);\n if (parentDir === currentDir) break;\n currentDir = parentDir;\n }\n}\n\nfunction toTempRelativePath(absolutePath: string): string {\n const { root } = path.parse(absolutePath);\n return path.relative(root, absolutePath);\n}\n"],"mappings":"yGASA,eAAsB,EACpB,EACA,EAGI,CAAC,EACoD,CACzD,IAAM,EAAW,MAAM,EAAG,SAAS,QAAQ,EAAK,KAAK,EAAG,OAAO,EAAG,0BAA0B,CAAC,EAC7F,EAAQ,oBAAoB,CAAQ,EACpC,GAAI,CACF,IAAM,EAAqB,EAAK,QAAQ,CAAU,EAC5C,EAAmB,EAAK,KAAK,EAAU,EAAmB,CAAkB,CAAC,EAUnF,OATA,MAAM,EAAG,SAAS,MAAM,EAAK,QAAQ,CAAgB,EAAG,CAAE,UAAW,EAAK,CAAC,EAC3E,MAAM,EAAG,SAAS,GAAG,EAAoB,EAAkB,CACzD,UAAW,GACX,OAAQ,EAGR,iBAAkB,EACpB,CAAC,EACD,MAAM,EAA8B,EAAU,CAAkB,EACzD,CAAE,WAAU,kBAAiB,CACtC,OAAS,EAAO,CAEd,MADA,MAAM,EAAwB,CAAQ,EAChC,CACR,CACF,CAMA,eAAsB,EAAwB,EAA+B,CAC3E,GAAI,CAEF,OADA,MAAM,EAAG,SAAS,GAAG,EAAK,CAAE,UAAW,GAAM,MAAO,EAAK,CAAC,EACnD,EACT,MAAQ,CACN,EAAkB,CAAG,EACrB,GAAI,CAEF,OADA,MAAM,EAAG,SAAS,GAAG,EAAK,CAAE,UAAW,GAAM,MAAO,EAAK,CAAC,EACnD,EACT,MAAQ,CACN,MAAO,EACT,CACF,CACF,CAGA,SAAgB,EAA4B,EAAsB,CAChE,GAAI,CAEF,OADA,EAAG,OAAO,EAAK,CAAE,UAAW,GAAM,MAAO,EAAK,CAAC,EACxC,EACT,MAAQ,CACN,EAAkB,CAAG,EACrB,GAAI,CAEF,OADA,EAAG,OAAO,EAAK,CAAE,UAAW,GAAM,MAAO,EAAK,CAAC,EACxC,EACT,MAAQ,CACN,MAAO,EACT,CACF,CACF,CAIA,SAAS,EAAkB,EAAmB,CACxC,QAAQ,WAAa,SAAS,EAAc,UAAU,QAAS,CAAC,KAAM,QAAS,CAAG,CAAC,CACzF,CAIA,eAAe,EAAuB,EAA+B,CACnE,GAAI,CAAC,EAAoB,CAAG,EAAG,MAAO,GAEtC,IAAI,MADgB,EAAG,SAAS,MAAM,CAAG,EAAA,CAC/B,eAAe,GAAK,EAAK,WAAW,MAAM,EAAG,SAAS,SAAS,CAAG,CAAC,EAC3E,MAAU,MAAM,GAAG,EAAI,uFAAuF,EAEhH,MAAO,EACT,CAEA,SAAS,EAAoB,EAAsB,CACjD,IAAM,EAAO,EAAK,SAAS,CAAG,EAC9B,OAAO,IAAS,gBAAkB,IAAS,MAC7C,CAEA,eAAe,EAA8B,EAAkB,EAAmC,CAChG,IAAI,EAAa,EAAK,QAAQ,CAAU,EACxC,OAAa,CACX,IAAM,EAAkB,EAAK,KAAK,EAAY,cAAc,EAC5D,GAAI,EAAG,WAAW,CAAe,EAAG,CAClC,IAAM,EAAoB,EAAK,KAAK,EAAU,EAAmB,CAAU,EAAG,cAAc,EAC5F,GAAI,CACF,MAAM,EAAG,SAAS,QAChB,EACA,EACA,QAAQ,WAAa,QAAU,WAAa,KAC9C,CACF,MAAQ,CAER,CACF,CAEA,IAAM,EAAY,EAAK,QAAQ,CAAU,EACzC,GAAI,IAAc,EAAY,MAC9B,EAAa,CACf,CACF,CAEA,SAAS,EAAmB,EAA8B,CACxD,GAAM,CAAE,QAAS,EAAK,MAAM,CAAY,EACxC,OAAO,EAAK,SAAS,EAAM,CAAY,CACzC"}
|
package/dist/index.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./helpers/
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./helpers/compareStdoutAsSpaceSeparatedTokens.cjs"),t=require("./types/testCaseResult.cjs"),n=require("./helpers/printTestCaseResult.cjs"),r=require("./helpers/sandboxUser.cjs"),i=require("./helpers/safeFs.cjs"),a=require("./helpers/compareExpectedOutputFiles.cjs"),o=require("./helpers/defaultStdioHarness.cjs"),s=require("./helpers/parseArgs.cjs"),c=require("./helpers/parseCsv.cjs"),l=require("./helpers/readTestCases.cjs"),u=require("./helpers/removeCommentsInSourceCode.cjs"),d=require("./helpers/runCommandInTemporaryPackageManagerProject.cjs"),f=require("./helpers/runPythonInTemporaryUvProject.cjs"),p=require("./helpers/sourceCodeGrammars.cjs"),m=require("./helpers/startHttpServer.cjs"),h=require("./types/decisionCode.cjs"),g=require("./types/problem.cjs");exports.CsvParseError=c.CsvParseError,exports.DecisionCode=h.DecisionCode,exports.HARNESS_FILE_PRESETS=o.HARNESS_FILE_PRESETS,exports.MAX_COMPARED_FILE_BYTES=a.MAX_COMPARED_FILE_BYTES,exports.MAX_REPORTED_FILE_BYTES=a.MAX_REPORTED_FILE_BYTES,exports.SANDBOX_ENV_PREFIX=r.SANDBOX_ENV_PREFIX,exports.SANDBOX_USER_ENV_NAME=r.SANDBOX_USER_ENV_NAME,exports.SANDBOX_WATCHDOG_GRACE_SECONDS=r.SANDBOX_WATCHDOG_GRACE_SECONDS,exports.TEST_CASE_RESULT_PREFIX=t.TEST_CASE_RESULT_PREFIX,exports.TIMEOUT_COMMAND=r.TIMEOUT_COMMAND,exports.codeRuleSchema=g.codeRuleSchema,exports.compareExpectedOutputFiles=a.compareExpectedOutputFiles,exports.compareStdoutAsSpaceSeparatedTokens=e.compareStdoutAsSpaceSeparatedTokens,exports.copyPackageManagerProjectFiles=d.copyPackageManagerProjectFiles,exports.copyWithoutFollowingSymlinks=i.copyWithoutFollowingSymlinks,exports.createDirectoryWithoutFollowingSymlinks=i.createDirectoryWithoutFollowingSymlinks,exports.encodeFileForTestCaseResult=n.encodeFileForTestCaseResult,exports.findDefaultStdioHarnessFiles=o.findDefaultStdioHarnessFiles,exports.forceRemove=r.forceRemove,exports.getSandboxUserEnvOverrides=r.getSandboxUserEnvOverrides,exports.getTrustedHelperEnv=r.getTrustedHelperEnv,exports.isContainedPath=i.isContainedPath,exports.isDefaultStdioHarnessSource=o.isDefaultStdioHarnessSource,exports.isSandboxWrappedCommand=r.isSandboxWrappedCommand,exports.judgeAgainstExpectations=a.judgeAgainstExpectations,exports.killSandboxUserProcesses=r.killSandboxUserProcesses,exports.languageIdToSourceCodeGrammar=p.languageIdToSourceCodeGrammar,exports.makeAccessibleToSandboxUser=r.makeAccessibleToSandboxUser,exports.makeTraversableBySandboxUser=r.makeTraversableBySandboxUser,exports.mergeComparisonOutputFiles=a.mergeComparisonOutputFiles,exports.normalizeCodeRule=g.normalizeCodeRule,exports.parseArgs=s.parseArgs,exports.parseCsv=c.parseCsv,exports.parseCsvRecords=c.parseCsvRecords,exports.prependInsideSandboxWrapper=r.prependInsideSandboxWrapper,exports.printTestCaseResult=n.printTestCaseResult,exports.problemMarkdownFrontMatterSchema=g.problemMarkdownFrontMatterSchema,exports.readTestCases=l.readTestCases,exports.relaxPermissionsAsSandboxUser=r.relaxPermissionsAsSandboxUser,exports.removeCommentsAndStringsInSourceCode=u.removeCommentsAndStringsInSourceCode,exports.removeCommentsInSourceCode=u.removeCommentsInSourceCode,exports.runCommandInTemporaryPackageManagerProject=d.runCommandInTemporaryPackageManagerProject,exports.runPythonInTemporaryUvProject=f.runPythonInTemporaryUvProject,exports.sandboxUserName=r.sandboxUserName,exports.startHttpServer=m.startHttpServer,exports.startSandboxTimeoutWatchdog=r.startSandboxTimeoutWatchdog,exports.testCaseResultSchema=t.testCaseResultSchema,exports.wrapCommandWithSandboxUser=r.wrapCommandWithSandboxUser,exports.writeFileWithoutFollowingSymlinks=i.writeFileWithoutFollowingSymlinks;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
export * from './helpers/compareExpectedOutputFiles.js';
|
|
2
|
+
export * from './helpers/compareStdoutAsSpaceSeparatedTokens.js';
|
|
1
3
|
export * from './helpers/defaultStdioHarness.js';
|
|
2
4
|
export * from './helpers/parseArgs.js';
|
|
3
5
|
export * from './helpers/parseCsv.js';
|
|
4
6
|
export * from './helpers/printTestCaseResult.js';
|
|
7
|
+
export * from './helpers/readTestCases.js';
|
|
5
8
|
export * from './helpers/removeCommentsInSourceCode.js';
|
|
6
9
|
export * from './helpers/runCommandInTemporaryPackageManagerProject.js';
|
|
7
10
|
export * from './helpers/runPythonInTemporaryUvProject.js';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{compareStdoutAsSpaceSeparatedTokens as e}from"./helpers/compareStdoutAsSpaceSeparatedTokens.js";import{TEST_CASE_RESULT_PREFIX as t,testCaseResultSchema as n}from"./types/testCaseResult.js";import{encodeFileForTestCaseResult as r,printTestCaseResult as i}from"./helpers/printTestCaseResult.js";import{SANDBOX_ENV_PREFIX as a,SANDBOX_USER_ENV_NAME as o,SANDBOX_WATCHDOG_GRACE_SECONDS as s,TIMEOUT_COMMAND as c,forceRemove as l,getSandboxUserEnvOverrides as u,getTrustedHelperEnv as d,isSandboxWrappedCommand as f,killSandboxUserProcesses as p,makeAccessibleToSandboxUser as m,makeTraversableBySandboxUser as h,prependInsideSandboxWrapper as g,relaxPermissionsAsSandboxUser as _,sandboxUserName as v,startSandboxTimeoutWatchdog as y,wrapCommandWithSandboxUser as b}from"./helpers/sandboxUser.js";import{copyWithoutFollowingSymlinks as x,createDirectoryWithoutFollowingSymlinks as S,isContainedPath as C,writeFileWithoutFollowingSymlinks as w}from"./helpers/safeFs.js";import{MAX_COMPARED_FILE_BYTES as T,MAX_REPORTED_FILE_BYTES as E,compareExpectedOutputFiles as D,judgeAgainstExpectations as O,mergeComparisonOutputFiles as k}from"./helpers/compareExpectedOutputFiles.js";import{HARNESS_FILE_PRESETS as A,findDefaultStdioHarnessFiles as j,isDefaultStdioHarnessSource as M}from"./helpers/defaultStdioHarness.js";import{parseArgs as N}from"./helpers/parseArgs.js";import{CsvParseError as P,parseCsv as F,parseCsvRecords as I}from"./helpers/parseCsv.js";import{readTestCases as L}from"./helpers/readTestCases.js";import{removeCommentsAndStringsInSourceCode as R,removeCommentsInSourceCode as z}from"./helpers/removeCommentsInSourceCode.js";import{copyPackageManagerProjectFiles as B,runCommandInTemporaryPackageManagerProject as V}from"./helpers/runCommandInTemporaryPackageManagerProject.js";import{runPythonInTemporaryUvProject as H}from"./helpers/runPythonInTemporaryUvProject.js";import{languageIdToSourceCodeGrammar as U}from"./helpers/sourceCodeGrammars.js";import{startHttpServer as W}from"./helpers/startHttpServer.js";import{DecisionCode as G}from"./types/decisionCode.js";import{codeRuleSchema as K,normalizeCodeRule as q,problemMarkdownFrontMatterSchema as J}from"./types/problem.js";export{P as CsvParseError,G as DecisionCode,A as HARNESS_FILE_PRESETS,T as MAX_COMPARED_FILE_BYTES,E as MAX_REPORTED_FILE_BYTES,a as SANDBOX_ENV_PREFIX,o as SANDBOX_USER_ENV_NAME,s as SANDBOX_WATCHDOG_GRACE_SECONDS,t as TEST_CASE_RESULT_PREFIX,c as TIMEOUT_COMMAND,K as codeRuleSchema,D as compareExpectedOutputFiles,e as compareStdoutAsSpaceSeparatedTokens,B as copyPackageManagerProjectFiles,x as copyWithoutFollowingSymlinks,S as createDirectoryWithoutFollowingSymlinks,r as encodeFileForTestCaseResult,j as findDefaultStdioHarnessFiles,l as forceRemove,u as getSandboxUserEnvOverrides,d as getTrustedHelperEnv,C as isContainedPath,M as isDefaultStdioHarnessSource,f as isSandboxWrappedCommand,O as judgeAgainstExpectations,p as killSandboxUserProcesses,U as languageIdToSourceCodeGrammar,m as makeAccessibleToSandboxUser,h as makeTraversableBySandboxUser,k as mergeComparisonOutputFiles,q as normalizeCodeRule,N as parseArgs,F as parseCsv,I as parseCsvRecords,g as prependInsideSandboxWrapper,i as printTestCaseResult,J as problemMarkdownFrontMatterSchema,L as readTestCases,_ as relaxPermissionsAsSandboxUser,R as removeCommentsAndStringsInSourceCode,z as removeCommentsInSourceCode,V as runCommandInTemporaryPackageManagerProject,H as runPythonInTemporaryUvProject,v as sandboxUserName,W as startHttpServer,y as startSandboxTimeoutWatchdog,n as testCaseResultSchema,b as wrapCommandWithSandboxUser,w as writeFileWithoutFollowingSymlinks};
|
package/dist/presets/command.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("../helpers/
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("../helpers/printTestCaseResult.cjs"),n=require("../helpers/sandboxUser.cjs"),r=require("../helpers/readOutputFiles.cjs"),i=require("../helpers/compareExpectedOutputFiles.cjs"),a=require("../helpers/parseArgs.cjs"),o=require("../helpers/readTestCases.cjs"),s=require("../types/decisionCode.cjs"),c=require("../helpers/cleanWorkingDirectory.cjs"),l=require("../helpers/copyTestCaseFileInput.cjs"),u=require("../helpers/findEntryPointFile.cjs"),d=require("../helpers/findLanguageDefinitionByPath.cjs"),f=require("../helpers/judgeByStaticAnalysis.cjs"),p=require("../helpers/runCustomRunner.cjs"),m=require("../helpers/runIsolationCheckInDebugMode.cjs"),h=require("../helpers/readProblemMarkdownFrontMatter.cjs"),g=require("../helpers/resolveCwds.cjs"),_=require("../helpers/spawnSyncWithTimeout.cjs");let v=require("node:path");v=e.__toESM(v,1);let y=require("zod");const b=y.z.object({language:y.z.union([y.z.string(),y.z.array(y.z.string())]).optional()});async function x(e,t={}){let n=a.parseArgs(process.argv),r=b.parse(n.params),{cwds:i,isDebugMode:o}=await g.resolveCwds(e,n.cwd),s=await h.readProblemMarkdownFrontMatter(e),c=await(t.readTestCases??E)(e),l={buildTimeoutSeconds:t.limits?.buildTimeoutSeconds??10,maxOutputLength:t.limits?.maxOutputLength??5e4},u=typeof s.timeLimitMs==`number`?s.timeLimitMs/1e3:t.runTimeoutSeconds??2,d={problemMarkdownFrontMatter:s,testCases:c,limits:l,timeLimitSeconds:u};if(o){let t=(l.buildTimeoutSeconds+c.length*u)*1e3;if(!await m.passesIsolationCheckInDebugMode(e,i,r,{expectedMaxDurationMs:t})){process.exitCode=1;return}}for(let n of i){o&&g.printDebugCwdBanner(e,n);let i=await S(n.cwd,r,d,t);o&&!C(n,i)&&(process.exitCode=1,g.printDebugExpectationFailureBanner(e,n))}}async function S(e,i,a,o){n.makeAccessibleToSandboxUser(e);let{problemMarkdownFrontMatter:m,testCases:h,limits:g,timeLimitSeconds:_}=a,v=h[0]?.id??`prebuild`,y=await f.judgeByStaticAnalysis(e,m);if(y)return t.printTestCaseResult({testCaseId:v,...y}),{allAccepted:!1};let b=await u.findEntryPointFile(e,i.language);if(!b)return t.printTestCaseResult({testCaseId:v,decisionCode:s.DecisionCode.MISSING_REQUIRED_SUBMISSION_FILE_ERROR,stderr:`main file not found${i.language?`: language: ${i.language}`:``}`}),{allAccepted:!1};let x=d.findLanguageDefinitionByPath(b);if(!x)return t.printTestCaseResult({testCaseId:v,decisionCode:s.DecisionCode.WRONG_ANSWER,stderr:`unsupported language`}),{allAccepted:!1};let S={...process.env,CI:``,FORCE_COLOR:`0`},C=b;if(o.buildSubmission!==!1){if(x.prebuild)try{await x.prebuild(e);let t=await u.findEntryPointFile(e,i.language);t&&(C=t)}catch(e){return t.printTestCaseResult({testCaseId:v,decisionCode:s.DecisionCode.BUILD_ERROR,stderr:e instanceof Error?e.message:String(e)}),{allAccepted:!1}}let n=x.buildCommand?.(C);if(n){let r=w(n,{cwd:e,env:S,testCaseId:v,limits:g});if(r)return t.printTestCaseResult(r),{allAccepted:!1}}}let E=await c.snapshotWorkingDirectory(e);if(h.length===0)return t.printTestCaseResult({testCaseId:`default`,decisionCode:s.DecisionCode.ACCEPTED}),{allAccepted:!0};let A=h.shared?.fileInputPath;for(let i of h){A&&await l.copyTestCaseFileInput(A,e),i.fileInputPath&&await l.copyTestCaseFileInput(i.fileInputPath,e);let a=x.command(C),u=i.input??``,d;try{o.resolveInput&&(u=await o.resolveInput({testCase:i,cwd:e,env:S})),d=o.runCommand?await p.runCustomRunner(()=>o.runCommand?.({testCase:i,command:n.wrapCommandWithSandboxUser(a),stdin:u,cwd:e,env:{...S,...n.getSandboxUserEnvOverrides(S)},timeLimitSeconds:_})):O(a,{stdin:u,cwd:e,env:S,timeLimitSeconds:_})}catch(n){return t.printTestCaseResult({testCaseId:i.id,decisionCode:s.DecisionCode.RUNTIME_ERROR,stdin:u,stderr:T(n)}),await c.cleanWorkingDirectory(e,E),{allAccepted:!1}}let f=await r.readOutputFiles(e,m.requiredOutputFilePaths??[]),h={timeLimitSeconds:_,outputLimitLength:g.maxOutputLength,problemMarkdownFrontMatter:{memoryLimitByte:m.memoryLimitByte,requiredOutputFilePaths:m.requiredOutputFilePaths}},v=k({runResult:d,outputFiles:f,context:h}),y=v;if(v.decisionCode===s.DecisionCode.ACCEPTED)try{let t=o.test?await o.test({testCase:i,runResult:d,outputFiles:f,cwd:e,context:h}):await D({testCase:i,runResult:d,outputFiles:f,cwd:e});t&&(y={decisionCode:t.decisionCode??v.decisionCode,feedbackMarkdown:t.feedbackMarkdown,stderr:t.stderr})}catch(e){y={decisionCode:s.DecisionCode.RUNTIME_ERROR,stderr:T(e)}}if(t.printTestCaseResult({testCaseId:i.id,...y,exitStatus:d.status,stdin:d.stdin,stdout:d.stdout.slice(0,g.maxOutputLength)||void 0,stderr:(y.stderr??d.stderr).slice(0,g.maxOutputLength)||void 0,timeSeconds:d.timeSeconds,memoryBytes:d.memoryBytes,outputFiles:f.length>0?f:void 0}),await c.cleanWorkingDirectory(e,E),y.decisionCode!==s.DecisionCode.ACCEPTED)return{allAccepted:!1}}return{allAccepted:!0}}function C(e,t){return t.allAccepted===(e.expectedResult===`accepted`)}function w(e,t){let n=_.spawnSyncWithTimeout(e[0],e.slice(1),{cwd:t.cwd,encoding:`utf8`,env:t.env},t.limits.buildTimeoutSeconds),r=n.status??void 0;if(n.status!==0)return{testCaseId:t.testCaseId,decisionCode:s.DecisionCode.BUILD_ERROR,exitStatus:r,stdout:n.stdout.slice(0,t.limits.maxOutputLength),stderr:n.stderr.slice(0,t.limits.maxOutputLength),timeSeconds:n.timeSeconds,memoryBytes:n.memoryBytes};if(n.timeSeconds>t.limits.buildTimeoutSeconds)return{testCaseId:t.testCaseId,decisionCode:s.DecisionCode.BUILD_TIME_LIMIT_EXCEEDED,exitStatus:r,stdout:n.stdout.slice(0,t.limits.maxOutputLength),stderr:n.stderr.slice(0,t.limits.maxOutputLength),timeSeconds:n.timeSeconds,memoryBytes:n.memoryBytes};if(n.stdout.length>t.limits.maxOutputLength||n.stderr.length>t.limits.maxOutputLength)return{testCaseId:t.testCaseId,decisionCode:s.DecisionCode.BUILD_OUTPUT_SIZE_LIMIT_EXCEEDED,exitStatus:r,stdout:n.stdout.slice(0,t.limits.maxOutputLength),stderr:n.stderr.slice(0,t.limits.maxOutputLength),timeSeconds:n.timeSeconds,memoryBytes:n.memoryBytes}}function T(e){return e instanceof Error?e.message:String(e)}async function E(e){return await o.readTestCases(v.default.join(e,`test_cases`))}async function D(e){let{testCase:t,runResult:n,outputFiles:r,cwd:a}=e,o=await i.judgeAgainstExpectations({stdout:n.stdout,expectedStdout:typeof t.output==`string`?t.output:void 0,fileOutputPath:typeof t.fileOutputPath==`string`?t.fileOutputPath:void 0,cwd:a,outputFiles:r});return r.splice(0,r.length,...o.outputFiles),o.matches?{}:{decisionCode:s.DecisionCode.WRONG_ANSWER}}function O(e,t){let n=_.spawnSyncWithTimeout(e[0],e.slice(1),{cwd:t.cwd,encoding:`utf8`,input:t.stdin,env:t.env},t.timeLimitSeconds);return{stdin:t.stdin,stdout:n.stdout,stderr:n.stderr,status:n.status??void 0,timeSeconds:n.timeSeconds,memoryBytes:n.memoryBytes}}function k(e){if(e.runResult.status!==0)return{decisionCode:s.DecisionCode.RUNTIME_ERROR,stderr:e.runResult.stderr};if(e.runResult.timeSeconds>e.context.timeLimitSeconds)return{decisionCode:s.DecisionCode.TIME_LIMIT_EXCEEDED,stderr:e.runResult.stderr};if(e.runResult.memoryBytes>(e.context.problemMarkdownFrontMatter.memoryLimitByte??1/0))return{decisionCode:s.DecisionCode.MEMORY_LIMIT_EXCEEDED,stderr:e.runResult.stderr};if(e.runResult.outputLimitExceeded||e.runResult.stdout.length>e.context.outputLimitLength||e.runResult.stderr.length>e.context.outputLimitLength)return{decisionCode:s.DecisionCode.OUTPUT_SIZE_LIMIT_EXCEEDED,stderr:e.runResult.stderr};let t=e.context.problemMarkdownFrontMatter.requiredOutputFilePaths?.length??0;return e.outputFiles.length<t?{decisionCode:s.DecisionCode.MISSING_REQUIRED_OUTPUT_FILE_ERROR}:{decisionCode:s.DecisionCode.ACCEPTED}}exports.commandJudgePreset=x;
|
|
2
2
|
//# sourceMappingURL=command.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"command.cjs","names":["z","parseArgs","resolveCwds","checkProblemDirIsolation","readProblemMarkdownFrontMatter","judgeByStaticAnalysis","findEntryPointFile","DecisionCode","findLanguageDefinitionByPath","snapshotWorkingDirectory","copyTestCaseFileInput","runCustomRunner","wrapCommandWithSandboxUser","getSandboxUserEnvOverrides","cleanWorkingDirectory","readOutputFiles","spawnSyncWithTimeout","readFileTestCases","path"],"sources":["../../src/presets/command.ts"],"sourcesContent":["import path from 'node:path';\n\nimport { z } from 'zod';\n\nimport { checkProblemDirIsolation } from '../helpers/checkProblemDirIsolation.js';\nimport { cleanWorkingDirectory, snapshotWorkingDirectory } from '../helpers/cleanWorkingDirectory.js';\nimport { copyTestCaseFileInput } from '../helpers/copyTestCaseFileInput.js';\nimport { findEntryPointFile } from '../helpers/findEntryPointFile.js';\nimport { findLanguageDefinitionByPath } from '../helpers/findLanguageDefinitionByPath.js';\nimport { judgeByStaticAnalysis } from '../helpers/judgeByStaticAnalysis.js';\nimport { parseArgs } from '../helpers/parseArgs.js';\nimport { printDebugBanner } from '../helpers/printDebugBanner.js';\nimport { printTestCaseResult } from '../helpers/printTestCaseResult.js';\nimport { readOutputFiles } from '../helpers/readOutputFiles.js';\nimport { runCustomRunner } from '../helpers/runCustomRunner.js';\nimport {\n getSandboxUserEnvOverrides,\n makeAccessibleToSandboxUser,\n wrapCommandWithSandboxUser,\n} from '../helpers/sandboxUser.js';\nimport { readProblemMarkdownFrontMatter } from '../helpers/readProblemMarkdownFrontMatter.js';\nimport { readTestCases as readFileTestCases } from '../helpers/readTestCases.js';\nimport {\n printDebugCwdBanner,\n printDebugExpectationFailureBanner,\n resolveCwds,\n type ResolvedCwd,\n} from '../helpers/resolveCwds.js';\nimport { spawnSyncWithTimeout } from '../helpers/spawnSyncWithTimeout.js';\nimport { DecisionCode } from '../types/decisionCode.js';\nimport type { ProblemMarkdownFrontMatter } from '../types/problem.js';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\nconst BUILD_TIMEOUT_SECONDS = 10;\nconst JUDGE_DEFAULT_TIMEOUT_SECONDS = 2;\nconst MAX_STDOUT_LENGTH = 50_000;\n\nconst judgeParamsSchema = z.object({\n language: z.union([z.string(), z.array(z.string())]).optional(),\n});\n\ntype JudgeParams = z.infer<typeof judgeParamsSchema>;\n\ninterface BaseCommandTestCase {\n id: string;\n input?: string;\n fileInputPath?: string;\n}\n\ntype CommandJudgeCaseResult = Pick<TestCaseResult, 'decisionCode' | 'feedbackMarkdown' | 'stderr'>;\n\nexport interface CommandRunResult {\n stdin: string;\n stdout: string;\n stderr: string;\n status: number | undefined;\n timeSeconds: number;\n memoryBytes: number;\n outputLimitExceeded?: boolean;\n}\n\ninterface CommandJudgeContext {\n timeLimitSeconds: number;\n outputLimitLength: number;\n problemMarkdownFrontMatter: Pick<ProblemMarkdownFrontMatter, 'memoryLimitByte' | 'requiredOutputFilePaths'>;\n}\n\nexport interface CommandJudgeLimits {\n buildTimeoutSeconds: number;\n maxOutputLength: number;\n}\n\nexport interface CommandJudgePresetOptions<\n TTestCase extends BaseCommandTestCase = BaseCommandTestCase,\n TRunResult extends CommandRunResult = CommandRunResult,\n> {\n /**\n * Set to false when the custom runner builds the submitted source itself.\n * Defaults to true.\n */\n buildSubmission?: boolean;\n limits?: CommandJudgeLimits;\n runTimeoutSeconds?: number;\n readTestCases?: (problemDir: string) => Promise<readonly TTestCase[]>;\n /**\n * Runs as the trusted harness user with the submission's `cwd`. Fixture files it creates there\n * must be written with `createDirectoryWithoutFollowingSymlinks`/`writeFileWithoutFollowingSymlinks`\n * (both exported): a submission can plant a symlink at a fixture path, and a plain `fs.writeFile`\n * would follow it into a file only the harness can write.\n */\n resolveInput?: (context: { testCase: TTestCase; cwd: string; env: NodeJS.ProcessEnv }) => Promise<string> | string;\n runCommand?: (context: {\n testCase: TTestCase;\n /**\n * The command to run. Under `EXERCODE_SANDBOX_USER` delegation it is already wrapped so it\n * executes as the sandbox user; spawn it as given (with the supplied `env`) instead of\n * reconstructing it, or the submission runs as the trusted harness user.\n *\n * Its direct child is then a root-owned `sudo` whose descendants belong to the sandbox user, so\n * the handler cannot signal them: enforce `timeLimitSeconds` with `startSandboxTimeoutWatchdog`\n * and `killSandboxUserProcesses` (both exported) rather than `child.kill()` or an outer\n * `timeout`. The preset terminates leftover sandbox processes after the handler returns.\n */\n command: readonly [string, ...string[]];\n stdin: string;\n cwd: string;\n env: NodeJS.ProcessEnv;\n timeLimitSeconds: number;\n }) => Promise<TRunResult> | TRunResult;\n test?: (context: {\n testCase: TTestCase;\n runResult: TRunResult;\n outputFiles: NonNullable<TestCaseResult['outputFiles']>;\n context: CommandJudgeContext;\n }) => Promise<Partial<CommandJudgeCaseResult>> | Partial<CommandJudgeCaseResult> | undefined;\n}\n\n/**\n * A preset function for judging by executable command.\n *\n * Keep problem-specific logic in `resolveInput` and `test`.\n *\n * @example\n * Create `judge.ts`:\n * ```ts\n * import { commandJudgePreset } from '@exercode/problem-utils/presets/command';\n * import { DecisionCode } from '@exercode/problem-utils';\n *\n * await commandJudgePreset(import.meta.dirname, {\n * readTestCases: async () => [\n * { id: '01', input: '1 2' },\n * ],\n * test: ({ runResult }) => {\n * return runResult.stdout.trim() === '3'\n * ? { decisionCode: DecisionCode.ACCEPTED }\n * : { decisionCode: DecisionCode.WRONG_ANSWER };\n * },\n * });\n * ```\n *\n * Run with the required parameters:\n * ```bash\n * bun judge.ts model_answers/python '{ \"language\": \"python\" }'\n * ```\n *\n * Run without a cwd argument to judge each `<problemDir>/model_answers/*` directory\n * for debugging. A prominent banner is printed for each run.\n * ```bash\n * bun judge.ts\n * ```\n */\nexport async function commandJudgePreset<\n TTestCase extends BaseCommandTestCase = BaseCommandTestCase,\n TRunResult extends CommandRunResult = CommandRunResult,\n>(problemDir: string, options: CommandJudgePresetOptions<TTestCase, TRunResult>): Promise<void> {\n const args = parseArgs(process.argv);\n const params = judgeParamsSchema.parse(args.params);\n\n const { cwds, isDebugMode } = await resolveCwds(problemDir, args.cwd);\n\n if (isDebugMode) {\n const acceptedCwd = cwds.find((cwd) => cwd.expectedResult === 'accepted');\n if (acceptedCwd) {\n const isolationCheckResult = await checkProblemDirIsolation(problemDir, acceptedCwd, params);\n if (!isolationCheckResult.passed) {\n process.exitCode = 1;\n return;\n }\n } else {\n printDebugBanner([\n '[DEBUG MODE] isolated problem directory check skipped',\n '',\n 'No accepted model answer is available for checking that the copied judge still accepts a valid submission.',\n ]);\n }\n }\n\n for (const resolvedCwd of cwds) {\n if (isDebugMode) printDebugCwdBanner(problemDir, resolvedCwd);\n const result = await runCommandJudgeForCwd<TTestCase, TRunResult>(problemDir, resolvedCwd.cwd, params, options);\n if (isDebugMode && !matchesExpectedResult(resolvedCwd, result)) {\n process.exitCode = 1;\n printDebugExpectationFailureBanner(problemDir, resolvedCwd);\n }\n }\n}\n\nasync function runCommandJudgeForCwd<\n TTestCase extends BaseCommandTestCase,\n TRunResult extends CommandRunResult = CommandRunResult,\n>(\n problemDir: string,\n cwd: string,\n params: JudgeParams,\n options: CommandJudgePresetOptions<TTestCase, TRunResult>\n): Promise<{ allAccepted: boolean }> {\n // The sandboxed submission must read its sources and write build/run outputs in its directory.\n makeAccessibleToSandboxUser(cwd);\n\n const problemMarkdownFrontMatter = await readProblemMarkdownFrontMatter(problemDir);\n const testCases = await (options.readTestCases ?? readCommandTestCases)(problemDir);\n const prebuildTestCaseId = testCases[0]?.id ?? 'prebuild';\n const limits = {\n buildTimeoutSeconds: options.limits?.buildTimeoutSeconds ?? BUILD_TIMEOUT_SECONDS,\n maxOutputLength: options.limits?.maxOutputLength ?? MAX_STDOUT_LENGTH,\n };\n const runTimeoutSeconds = options.runTimeoutSeconds ?? JUDGE_DEFAULT_TIMEOUT_SECONDS;\n\n const staticAnalysisResult = await judgeByStaticAnalysis(cwd, problemMarkdownFrontMatter);\n if (staticAnalysisResult) {\n printTestCaseResult({ testCaseId: prebuildTestCaseId, ...staticAnalysisResult });\n return { allAccepted: false };\n }\n\n const originalMainFilePath = await findEntryPointFile(cwd, params.language);\n if (!originalMainFilePath) {\n printTestCaseResult({\n testCaseId: prebuildTestCaseId,\n decisionCode: DecisionCode.MISSING_REQUIRED_SUBMISSION_FILE_ERROR,\n stderr: `main file not found${params.language ? `: language: ${params.language}` : ''}`,\n });\n return { allAccepted: false };\n }\n\n const languageDefinition = findLanguageDefinitionByPath(originalMainFilePath);\n if (!languageDefinition) {\n printTestCaseResult({\n testCaseId: prebuildTestCaseId,\n decisionCode: DecisionCode.WRONG_ANSWER,\n stderr: 'unsupported language',\n });\n return { allAccepted: false };\n }\n\n // `CI` changes affects Chainlit. `FORCE_COLOR` affects Bun.\n const env = { ...process.env, CI: '', FORCE_COLOR: '0' };\n\n let mainFilePath = originalMainFilePath;\n if (options.buildSubmission !== false) {\n if (languageDefinition.prebuild) {\n try {\n await languageDefinition.prebuild(cwd);\n const prebuiltMainFilePath = await findEntryPointFile(cwd, params.language);\n if (prebuiltMainFilePath) mainFilePath = prebuiltMainFilePath;\n } catch (error) {\n printTestCaseResult({\n testCaseId: prebuildTestCaseId,\n decisionCode: DecisionCode.BUILD_ERROR,\n stderr: error instanceof Error ? error.message : String(error),\n });\n return { allAccepted: false };\n }\n }\n\n const buildCommand = languageDefinition.buildCommand?.(mainFilePath);\n if (buildCommand) {\n const buildResult = runBuild(buildCommand, {\n cwd,\n env,\n testCaseId: prebuildTestCaseId,\n limits,\n });\n if (buildResult) {\n printTestCaseResult(buildResult);\n return { allAccepted: false };\n }\n }\n }\n\n const cwdSnapshot = await snapshotWorkingDirectory(cwd);\n\n if (testCases.length === 0) {\n printTestCaseResult({ testCaseId: 'default', decisionCode: DecisionCode.ACCEPTED });\n return { allAccepted: true };\n }\n\n const sharedFileInputPath = (testCases as { shared?: { fileInputPath?: string } }).shared?.fileInputPath;\n\n for (const testCase of testCases) {\n if (sharedFileInputPath) await copyTestCaseFileInput(sharedFileInputPath, cwd);\n if (testCase.fileInputPath) await copyTestCaseFileInput(testCase.fileInputPath, cwd);\n\n const timeLimitSeconds =\n typeof problemMarkdownFrontMatter.timeLimitMs === 'number'\n ? problemMarkdownFrontMatter.timeLimitMs / 1000\n : runTimeoutSeconds;\n\n const command = languageDefinition.command(mainFilePath);\n let stdin = testCase.input ?? '';\n let runResult: TRunResult;\n try {\n if (options.resolveInput) {\n stdin = await options.resolveInput({ testCase, cwd, env });\n }\n\n runResult = options.runCommand\n ? await runCustomRunner(\n () =>\n // Hand custom runners a command that is already sandbox-wrapped, and the matching\n // environment: they spawn it themselves, so an unwrapped command would run the\n // submission as the trusted harness user and defeat the delegation boundary.\n options.runCommand?.({\n testCase,\n command: wrapCommandWithSandboxUser(command),\n stdin,\n cwd,\n env: { ...env, ...getSandboxUserEnvOverrides(env) },\n timeLimitSeconds,\n }) as Promise<TRunResult>\n )\n : (runCommand(command, {\n stdin,\n cwd,\n env,\n timeLimitSeconds,\n }) as TRunResult);\n } catch (error) {\n printTestCaseResult({\n testCaseId: testCase.id,\n decisionCode: DecisionCode.RUNTIME_ERROR,\n stdin,\n stderr: errorToMessage(error),\n });\n await cleanWorkingDirectory(cwd, cwdSnapshot);\n return { allAccepted: false };\n }\n\n const outputFiles = await readOutputFiles(cwd, problemMarkdownFrontMatter.requiredOutputFilePaths ?? []);\n const judgeContext: CommandJudgeContext = {\n timeLimitSeconds,\n outputLimitLength: limits.maxOutputLength,\n problemMarkdownFrontMatter: {\n memoryLimitByte: problemMarkdownFrontMatter.memoryLimitByte,\n requiredOutputFilePaths: problemMarkdownFrontMatter.requiredOutputFilePaths,\n },\n };\n const baseJudgeResult = evaluateByLimits({ runResult, outputFiles, context: judgeContext });\n let judgeResult = baseJudgeResult;\n if (baseJudgeResult.decisionCode === DecisionCode.ACCEPTED) {\n try {\n const extendedJudgeResult = await options.test?.({ testCase, runResult, outputFiles, context: judgeContext });\n if (extendedJudgeResult) {\n judgeResult = {\n decisionCode: extendedJudgeResult.decisionCode ?? baseJudgeResult.decisionCode,\n feedbackMarkdown: extendedJudgeResult.feedbackMarkdown,\n stderr: extendedJudgeResult.stderr,\n };\n }\n } catch (error) {\n judgeResult = {\n decisionCode: DecisionCode.RUNTIME_ERROR,\n stderr: errorToMessage(error),\n };\n }\n }\n\n printTestCaseResult({\n testCaseId: testCase.id,\n ...judgeResult,\n exitStatus: runResult.status,\n stdin: runResult.stdin,\n stdout: runResult.stdout.slice(0, limits.maxOutputLength) || undefined,\n stderr: (judgeResult.stderr ?? runResult.stderr).slice(0, limits.maxOutputLength) || undefined,\n timeSeconds: runResult.timeSeconds,\n memoryBytes: runResult.memoryBytes,\n outputFiles: outputFiles.length > 0 ? outputFiles : undefined,\n });\n\n await cleanWorkingDirectory(cwd, cwdSnapshot);\n if (judgeResult.decisionCode !== DecisionCode.ACCEPTED) return { allAccepted: false };\n }\n\n return { allAccepted: true };\n}\n\nfunction matchesExpectedResult(resolvedCwd: ResolvedCwd, result: { allAccepted: boolean }): boolean {\n return result.allAccepted === (resolvedCwd.expectedResult === 'accepted');\n}\n\nfunction runBuild(\n buildCommand: readonly [string, ...string[]],\n context: { cwd: string; env: NodeJS.ProcessEnv; testCaseId: string; limits: CommandJudgeLimits }\n): (Omit<TestCaseResult, 'testCaseId'> & { testCaseId: string }) | undefined {\n const spawnResult = spawnSyncWithTimeout(\n buildCommand[0],\n buildCommand.slice(1),\n { cwd: context.cwd, encoding: 'utf8', env: context.env },\n context.limits.buildTimeoutSeconds\n );\n const exitStatus = spawnResult.status ?? undefined;\n\n if (spawnResult.status !== 0) {\n return {\n testCaseId: context.testCaseId,\n decisionCode: DecisionCode.BUILD_ERROR,\n exitStatus,\n stdout: spawnResult.stdout.slice(0, context.limits.maxOutputLength),\n stderr: spawnResult.stderr.slice(0, context.limits.maxOutputLength),\n timeSeconds: spawnResult.timeSeconds,\n memoryBytes: spawnResult.memoryBytes,\n };\n }\n\n if (spawnResult.timeSeconds > context.limits.buildTimeoutSeconds) {\n return {\n testCaseId: context.testCaseId,\n decisionCode: DecisionCode.BUILD_TIME_LIMIT_EXCEEDED,\n exitStatus,\n stdout: spawnResult.stdout.slice(0, context.limits.maxOutputLength),\n stderr: spawnResult.stderr.slice(0, context.limits.maxOutputLength),\n timeSeconds: spawnResult.timeSeconds,\n memoryBytes: spawnResult.memoryBytes,\n };\n }\n\n if (\n spawnResult.stdout.length > context.limits.maxOutputLength ||\n spawnResult.stderr.length > context.limits.maxOutputLength\n ) {\n return {\n testCaseId: context.testCaseId,\n decisionCode: DecisionCode.BUILD_OUTPUT_SIZE_LIMIT_EXCEEDED,\n exitStatus,\n stdout: spawnResult.stdout.slice(0, context.limits.maxOutputLength),\n stderr: spawnResult.stderr.slice(0, context.limits.maxOutputLength),\n timeSeconds: spawnResult.timeSeconds,\n memoryBytes: spawnResult.memoryBytes,\n };\n }\n\n return;\n}\n\nfunction errorToMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nfunction toCommandTestCase(value: {\n id: string;\n input?: string;\n fileInputPath?: string;\n fileOutputPath?: string;\n output?: string;\n}): BaseCommandTestCase {\n return { id: value.id, input: value.input, fileInputPath: value.fileInputPath };\n}\n\nasync function readCommandTestCases<TTestCase extends BaseCommandTestCase = BaseCommandTestCase>(\n problemDir: string\n): Promise<readonly TTestCase[]> {\n const fileTestCases = await readFileTestCases(path.join(problemDir, 'test_cases'));\n const commandTestCases = fileTestCases.map((testCase) => toCommandTestCase(testCase) as TTestCase);\n if (fileTestCases.shared?.fileInputPath) {\n return Object.assign(commandTestCases, { shared: { fileInputPath: fileTestCases.shared.fileInputPath } });\n }\n return commandTestCases;\n}\n\nfunction runCommand(\n command: readonly [string, ...string[]],\n context: {\n stdin: string;\n cwd: string;\n env: NodeJS.ProcessEnv;\n timeLimitSeconds: number;\n }\n): CommandRunResult {\n const spawnResult = spawnSyncWithTimeout(\n command[0],\n command.slice(1),\n { cwd: context.cwd, encoding: 'utf8', input: context.stdin, env: context.env },\n context.timeLimitSeconds\n );\n\n return {\n stdin: context.stdin,\n stdout: spawnResult.stdout,\n stderr: spawnResult.stderr,\n status: spawnResult.status ?? undefined,\n timeSeconds: spawnResult.timeSeconds,\n memoryBytes: spawnResult.memoryBytes,\n };\n}\n\nfunction evaluateByLimits(context: {\n runResult: CommandRunResult;\n outputFiles: NonNullable<TestCaseResult['outputFiles']>;\n context: CommandJudgeContext;\n}): CommandJudgeCaseResult {\n if (context.runResult.status !== 0) {\n return { decisionCode: DecisionCode.RUNTIME_ERROR, stderr: context.runResult.stderr };\n }\n\n if (context.runResult.timeSeconds > context.context.timeLimitSeconds) {\n return { decisionCode: DecisionCode.TIME_LIMIT_EXCEEDED, stderr: context.runResult.stderr };\n }\n\n if (\n context.runResult.memoryBytes >\n (context.context.problemMarkdownFrontMatter.memoryLimitByte ?? Number.POSITIVE_INFINITY)\n ) {\n return { decisionCode: DecisionCode.MEMORY_LIMIT_EXCEEDED, stderr: context.runResult.stderr };\n }\n\n if (\n context.runResult.outputLimitExceeded ||\n context.runResult.stdout.length > context.context.outputLimitLength ||\n context.runResult.stderr.length > context.context.outputLimitLength\n ) {\n return { decisionCode: DecisionCode.OUTPUT_SIZE_LIMIT_EXCEEDED, stderr: context.runResult.stderr };\n }\n\n const requiredOutputFileCount = context.context.problemMarkdownFrontMatter.requiredOutputFilePaths?.length ?? 0;\n if (context.outputFiles.length < requiredOutputFileCount) {\n return { decisionCode: DecisionCode.MISSING_REQUIRED_OUTPUT_FILE_ERROR };\n }\n\n return { decisionCode: DecisionCode.ACCEPTED };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"command.cjs","names":["z","parseArgs","resolveCwds","readProblemMarkdownFrontMatter","passesIsolationCheckInDebugMode","judgeByStaticAnalysis","findEntryPointFile","DecisionCode","findLanguageDefinitionByPath","snapshotWorkingDirectory","copyTestCaseFileInput","runCustomRunner","wrapCommandWithSandboxUser","getSandboxUserEnvOverrides","cleanWorkingDirectory","readOutputFiles","spawnSyncWithTimeout","readFileTestCases","path","judgeAgainstExpectations"],"sources":["../../src/presets/command.ts"],"sourcesContent":["import path from 'node:path';\n\nimport { z } from 'zod';\n\nimport { cleanWorkingDirectory, snapshotWorkingDirectory } from '../helpers/cleanWorkingDirectory.js';\nimport { judgeAgainstExpectations } from '../helpers/compareExpectedOutputFiles.js';\nimport { copyTestCaseFileInput } from '../helpers/copyTestCaseFileInput.js';\nimport { findEntryPointFile } from '../helpers/findEntryPointFile.js';\nimport { findLanguageDefinitionByPath } from '../helpers/findLanguageDefinitionByPath.js';\nimport { judgeByStaticAnalysis } from '../helpers/judgeByStaticAnalysis.js';\nimport { parseArgs } from '../helpers/parseArgs.js';\nimport { printTestCaseResult } from '../helpers/printTestCaseResult.js';\nimport { readOutputFiles } from '../helpers/readOutputFiles.js';\nimport { runCustomRunner } from '../helpers/runCustomRunner.js';\nimport { passesIsolationCheckInDebugMode } from '../helpers/runIsolationCheckInDebugMode.js';\nimport {\n getSandboxUserEnvOverrides,\n makeAccessibleToSandboxUser,\n wrapCommandWithSandboxUser,\n} from '../helpers/sandboxUser.js';\nimport { readProblemMarkdownFrontMatter } from '../helpers/readProblemMarkdownFrontMatter.js';\nimport { readTestCases as readFileTestCases } from '../helpers/readTestCases.js';\nimport {\n printDebugCwdBanner,\n printDebugExpectationFailureBanner,\n resolveCwds,\n type ResolvedCwd,\n} from '../helpers/resolveCwds.js';\nimport { spawnSyncWithTimeout } from '../helpers/spawnSyncWithTimeout.js';\nimport { DecisionCode } from '../types/decisionCode.js';\nimport type { ProblemMarkdownFrontMatter } from '../types/problem.js';\nimport type { TestCaseResult } from '../types/testCaseResult.js';\n\nconst BUILD_TIMEOUT_SECONDS = 10;\nconst JUDGE_DEFAULT_TIMEOUT_SECONDS = 2;\nconst MAX_STDOUT_LENGTH = 50_000;\n\nconst judgeParamsSchema = z.object({\n language: z.union([z.string(), z.array(z.string())]).optional(),\n});\n\ntype JudgeParams = z.infer<typeof judgeParamsSchema>;\n\n/** What every command test case needs; a custom `readTestCases` may add any fields of its own. */\ninterface BaseCommandTestCase {\n id: string;\n /** Standard input (`test_cases/<id>.in`). */\n input?: string;\n /** Directory copied into the working directory before the run (`test_cases/<id>.fin/`). */\n fileInputPath?: string;\n}\n\n/** A test case read from `test_cases/` by the default reader. */\nexport interface CommandTestCase extends BaseCommandTestCase {\n /** Expected standard output (`test_cases/<id>.out`). */\n output?: string;\n /** Directory of expected output files compared after the run (`test_cases/<id>.fout/`). */\n fileOutputPath?: string;\n}\n\ntype CommandJudgeCaseResult = Pick<TestCaseResult, 'decisionCode' | 'feedbackMarkdown' | 'stderr'>;\n\nexport interface CommandRunResult {\n stdin: string;\n stdout: string;\n stderr: string;\n status: number | undefined;\n timeSeconds: number;\n memoryBytes: number;\n outputLimitExceeded?: boolean;\n}\n\ninterface CommandJudgeContext {\n timeLimitSeconds: number;\n outputLimitLength: number;\n problemMarkdownFrontMatter: Pick<ProblemMarkdownFrontMatter, 'memoryLimitByte' | 'requiredOutputFilePaths'>;\n}\n\nexport interface CommandJudgeLimits {\n buildTimeoutSeconds: number;\n maxOutputLength: number;\n}\n\ninterface ResolvedCommandProblem<TTestCase extends BaseCommandTestCase> {\n problemMarkdownFrontMatter: ProblemMarkdownFrontMatter;\n testCases: readonly TTestCase[];\n limits: CommandJudgeLimits;\n timeLimitSeconds: number;\n}\n\nexport interface CommandJudgePresetOptions<\n TTestCase extends BaseCommandTestCase = CommandTestCase,\n TRunResult extends CommandRunResult = CommandRunResult,\n> {\n /**\n * Set to false when the custom runner builds the submitted source itself.\n * Defaults to true.\n */\n buildSubmission?: boolean;\n limits?: CommandJudgeLimits;\n /** The per-case time limit used only when `problem.md` declares no `timeLimitMs`. */\n runTimeoutSeconds?: number;\n readTestCases?: (problemDir: string) => Promise<readonly TTestCase[]>;\n /**\n * Runs as the trusted harness user with the submission's `cwd`. Fixture files it creates there\n * must be written with `createDirectoryWithoutFollowingSymlinks`/`writeFileWithoutFollowingSymlinks`\n * (both exported): a submission can plant a symlink at a fixture path, and a plain `fs.writeFile`\n * would follow it into a file only the harness can write.\n */\n resolveInput?: (context: { testCase: TTestCase; cwd: string; env: NodeJS.ProcessEnv }) => Promise<string> | string;\n runCommand?: (context: {\n testCase: TTestCase;\n /**\n * The command to run. Under `EXERCODE_SANDBOX_USER` delegation it is already wrapped so it\n * executes as the sandbox user; spawn it as given (with the supplied `env`) instead of\n * reconstructing it, or the submission runs as the trusted harness user.\n *\n * Its direct child is then a root-owned `sudo` whose descendants belong to the sandbox user, so\n * the handler cannot signal them: enforce `timeLimitSeconds` with `startSandboxTimeoutWatchdog`\n * and `killSandboxUserProcesses` (both exported) rather than `child.kill()` or an outer\n * `timeout`. The preset terminates leftover sandbox processes after the handler returns.\n */\n command: readonly [string, ...string[]];\n stdin: string;\n cwd: string;\n env: NodeJS.ProcessEnv;\n timeLimitSeconds: number;\n }) => Promise<TRunResult> | TRunResult;\n /**\n * Decides the verdict of a run that passed the limit checks. It replaces the default comparison\n * of `testCase.output` (space-separated tokens) and `testCase.fileOutputPath` (see\n * `compareStdoutAsSpaceSeparatedTokens` and `compareExpectedOutputFiles`, both exported), so\n * call those yourself when the test case ships `.out` / `.fout` expectations to keep.\n * `outputFiles` is the array printed with the result and may be edited in place.\n */\n test?: (context: {\n testCase: TTestCase;\n runResult: TRunResult;\n outputFiles: NonNullable<TestCaseResult['outputFiles']>;\n /** The submission's working directory, e.g. for `compareExpectedOutputFiles(cwd, testCase.fileOutputPath)`. */\n cwd: string;\n context: CommandJudgeContext;\n }) => Promise<Partial<CommandJudgeCaseResult>> | Partial<CommandJudgeCaseResult> | undefined;\n}\n\n/**\n * A preset function for judging by executable command.\n *\n * Without options, test cases come from `test_cases/` (`.in`, `.out`, `.fin/`, `.fout/`) and a run\n * within the limits is accepted when its stdout matches `.out` and its files match `.fout/`; a\n * case without either expectation only has to run within the limits.\n * Keep problem-specific logic in `resolveInput` and `test`.\n *\n * @example\n * Create `judge.ts` that judges `test_cases/` like the default stdio harness, with the command\n * preset's debug mode:\n * ```ts\n * import { commandJudgePreset } from '@exercode/problem-utils/presets/command';\n *\n * await commandJudgePreset(import.meta.dirname);\n * ```\n *\n * @example\n * Create `judge.ts` with an own verdict:\n * ```ts\n * import { commandJudgePreset } from '@exercode/problem-utils/presets/command';\n * import { DecisionCode } from '@exercode/problem-utils';\n *\n * await commandJudgePreset(import.meta.dirname, {\n * readTestCases: async () => [\n * { id: '01', input: '1 2' },\n * ],\n * test: ({ runResult }) => {\n * return runResult.stdout.trim() === '3'\n * ? { decisionCode: DecisionCode.ACCEPTED }\n * : { decisionCode: DecisionCode.WRONG_ANSWER };\n * },\n * });\n * ```\n *\n * Run with the required parameters:\n * ```bash\n * bun judge.ts model_answers/python '{ \"language\": \"python\" }'\n * ```\n *\n * Run without a cwd argument to judge each `<problemDir>/model_answers/*` directory\n * for debugging. A prominent banner is printed for each run.\n * ```bash\n * bun judge.ts\n * ```\n */\nexport async function commandJudgePreset<\n TTestCase extends BaseCommandTestCase = CommandTestCase,\n TRunResult extends CommandRunResult = CommandRunResult,\n>(problemDir: string, options: CommandJudgePresetOptions<TTestCase, TRunResult> = {}): Promise<void> {\n const args = parseArgs(process.argv);\n const params = judgeParamsSchema.parse(args.params);\n\n const { cwds, isDebugMode } = await resolveCwds(problemDir, args.cwd);\n const problemMarkdownFrontMatter = await readProblemMarkdownFrontMatter(problemDir);\n const testCases = await (options.readTestCases ?? readCommandTestCases)(problemDir);\n const limits = {\n buildTimeoutSeconds: options.limits?.buildTimeoutSeconds ?? BUILD_TIMEOUT_SECONDS,\n maxOutputLength: options.limits?.maxOutputLength ?? MAX_STDOUT_LENGTH,\n };\n const timeLimitSeconds =\n typeof problemMarkdownFrontMatter.timeLimitMs === 'number'\n ? problemMarkdownFrontMatter.timeLimitMs / 1000\n : (options.runTimeoutSeconds ?? JUDGE_DEFAULT_TIMEOUT_SECONDS);\n const problem: ResolvedCommandProblem<TTestCase> = {\n problemMarkdownFrontMatter,\n testCases,\n limits,\n timeLimitSeconds,\n };\n\n if (isDebugMode) {\n const expectedMaxDurationMs = (limits.buildTimeoutSeconds + testCases.length * timeLimitSeconds) * 1000;\n if (!(await passesIsolationCheckInDebugMode(problemDir, cwds, params, { expectedMaxDurationMs }))) {\n process.exitCode = 1;\n return;\n }\n }\n\n for (const resolvedCwd of cwds) {\n if (isDebugMode) printDebugCwdBanner(problemDir, resolvedCwd);\n const result = await runCommandJudgeForCwd<TTestCase, TRunResult>(resolvedCwd.cwd, params, problem, options);\n if (isDebugMode && !matchesExpectedResult(resolvedCwd, result)) {\n process.exitCode = 1;\n printDebugExpectationFailureBanner(problemDir, resolvedCwd);\n }\n }\n}\n\nasync function runCommandJudgeForCwd<\n TTestCase extends BaseCommandTestCase,\n TRunResult extends CommandRunResult = CommandRunResult,\n>(\n cwd: string,\n params: JudgeParams,\n problem: ResolvedCommandProblem<TTestCase>,\n options: CommandJudgePresetOptions<TTestCase, TRunResult>\n): Promise<{ allAccepted: boolean }> {\n // The sandboxed submission must read its sources and write build/run outputs in its directory.\n makeAccessibleToSandboxUser(cwd);\n\n const { problemMarkdownFrontMatter, testCases, limits, timeLimitSeconds } = problem;\n const prebuildTestCaseId = testCases[0]?.id ?? 'prebuild';\n\n const staticAnalysisResult = await judgeByStaticAnalysis(cwd, problemMarkdownFrontMatter);\n if (staticAnalysisResult) {\n printTestCaseResult({ testCaseId: prebuildTestCaseId, ...staticAnalysisResult });\n return { allAccepted: false };\n }\n\n const originalMainFilePath = await findEntryPointFile(cwd, params.language);\n if (!originalMainFilePath) {\n printTestCaseResult({\n testCaseId: prebuildTestCaseId,\n decisionCode: DecisionCode.MISSING_REQUIRED_SUBMISSION_FILE_ERROR,\n stderr: `main file not found${params.language ? `: language: ${params.language}` : ''}`,\n });\n return { allAccepted: false };\n }\n\n const languageDefinition = findLanguageDefinitionByPath(originalMainFilePath);\n if (!languageDefinition) {\n printTestCaseResult({\n testCaseId: prebuildTestCaseId,\n decisionCode: DecisionCode.WRONG_ANSWER,\n stderr: 'unsupported language',\n });\n return { allAccepted: false };\n }\n\n // `CI` changes affects Chainlit. `FORCE_COLOR` affects Bun.\n const env = { ...process.env, CI: '', FORCE_COLOR: '0' };\n\n let mainFilePath = originalMainFilePath;\n if (options.buildSubmission !== false) {\n if (languageDefinition.prebuild) {\n try {\n await languageDefinition.prebuild(cwd);\n const prebuiltMainFilePath = await findEntryPointFile(cwd, params.language);\n if (prebuiltMainFilePath) mainFilePath = prebuiltMainFilePath;\n } catch (error) {\n printTestCaseResult({\n testCaseId: prebuildTestCaseId,\n decisionCode: DecisionCode.BUILD_ERROR,\n stderr: error instanceof Error ? error.message : String(error),\n });\n return { allAccepted: false };\n }\n }\n\n const buildCommand = languageDefinition.buildCommand?.(mainFilePath);\n if (buildCommand) {\n const buildResult = runBuild(buildCommand, {\n cwd,\n env,\n testCaseId: prebuildTestCaseId,\n limits,\n });\n if (buildResult) {\n printTestCaseResult(buildResult);\n return { allAccepted: false };\n }\n }\n }\n\n const cwdSnapshot = await snapshotWorkingDirectory(cwd);\n\n if (testCases.length === 0) {\n printTestCaseResult({ testCaseId: 'default', decisionCode: DecisionCode.ACCEPTED });\n return { allAccepted: true };\n }\n\n const sharedFileInputPath = (testCases as { shared?: { fileInputPath?: string } }).shared?.fileInputPath;\n\n for (const testCase of testCases) {\n if (sharedFileInputPath) await copyTestCaseFileInput(sharedFileInputPath, cwd);\n if (testCase.fileInputPath) await copyTestCaseFileInput(testCase.fileInputPath, cwd);\n\n const command = languageDefinition.command(mainFilePath);\n let stdin = testCase.input ?? '';\n let runResult: TRunResult;\n try {\n if (options.resolveInput) {\n stdin = await options.resolveInput({ testCase, cwd, env });\n }\n\n runResult = options.runCommand\n ? await runCustomRunner(\n () =>\n // Hand custom runners a command that is already sandbox-wrapped, and the matching\n // environment: they spawn it themselves, so an unwrapped command would run the\n // submission as the trusted harness user and defeat the delegation boundary.\n options.runCommand?.({\n testCase,\n command: wrapCommandWithSandboxUser(command),\n stdin,\n cwd,\n env: { ...env, ...getSandboxUserEnvOverrides(env) },\n timeLimitSeconds,\n }) as Promise<TRunResult>\n )\n : (runCommand(command, {\n stdin,\n cwd,\n env,\n timeLimitSeconds,\n }) as TRunResult);\n } catch (error) {\n printTestCaseResult({\n testCaseId: testCase.id,\n decisionCode: DecisionCode.RUNTIME_ERROR,\n stdin,\n stderr: errorToMessage(error),\n });\n await cleanWorkingDirectory(cwd, cwdSnapshot);\n return { allAccepted: false };\n }\n\n const outputFiles = await readOutputFiles(cwd, problemMarkdownFrontMatter.requiredOutputFilePaths ?? []);\n const judgeContext: CommandJudgeContext = {\n timeLimitSeconds,\n outputLimitLength: limits.maxOutputLength,\n problemMarkdownFrontMatter: {\n memoryLimitByte: problemMarkdownFrontMatter.memoryLimitByte,\n requiredOutputFilePaths: problemMarkdownFrontMatter.requiredOutputFilePaths,\n },\n };\n const baseJudgeResult = evaluateByLimits({ runResult, outputFiles, context: judgeContext });\n let judgeResult = baseJudgeResult;\n if (baseJudgeResult.decisionCode === DecisionCode.ACCEPTED) {\n try {\n const extendedJudgeResult = options.test\n ? await options.test({ testCase, runResult, outputFiles, cwd, context: judgeContext })\n : await compareWithExpectedOutputs({ testCase, runResult, outputFiles, cwd });\n if (extendedJudgeResult) {\n judgeResult = {\n decisionCode: extendedJudgeResult.decisionCode ?? baseJudgeResult.decisionCode,\n feedbackMarkdown: extendedJudgeResult.feedbackMarkdown,\n stderr: extendedJudgeResult.stderr,\n };\n }\n } catch (error) {\n judgeResult = {\n decisionCode: DecisionCode.RUNTIME_ERROR,\n stderr: errorToMessage(error),\n };\n }\n }\n\n printTestCaseResult({\n testCaseId: testCase.id,\n ...judgeResult,\n exitStatus: runResult.status,\n stdin: runResult.stdin,\n stdout: runResult.stdout.slice(0, limits.maxOutputLength) || undefined,\n stderr: (judgeResult.stderr ?? runResult.stderr).slice(0, limits.maxOutputLength) || undefined,\n timeSeconds: runResult.timeSeconds,\n memoryBytes: runResult.memoryBytes,\n outputFiles: outputFiles.length > 0 ? outputFiles : undefined,\n });\n\n await cleanWorkingDirectory(cwd, cwdSnapshot);\n if (judgeResult.decisionCode !== DecisionCode.ACCEPTED) return { allAccepted: false };\n }\n\n return { allAccepted: true };\n}\n\nfunction matchesExpectedResult(resolvedCwd: ResolvedCwd, result: { allAccepted: boolean }): boolean {\n return result.allAccepted === (resolvedCwd.expectedResult === 'accepted');\n}\n\nfunction runBuild(\n buildCommand: readonly [string, ...string[]],\n context: { cwd: string; env: NodeJS.ProcessEnv; testCaseId: string; limits: CommandJudgeLimits }\n): (Omit<TestCaseResult, 'testCaseId'> & { testCaseId: string }) | undefined {\n const spawnResult = spawnSyncWithTimeout(\n buildCommand[0],\n buildCommand.slice(1),\n { cwd: context.cwd, encoding: 'utf8', env: context.env },\n context.limits.buildTimeoutSeconds\n );\n const exitStatus = spawnResult.status ?? undefined;\n\n if (spawnResult.status !== 0) {\n return {\n testCaseId: context.testCaseId,\n decisionCode: DecisionCode.BUILD_ERROR,\n exitStatus,\n stdout: spawnResult.stdout.slice(0, context.limits.maxOutputLength),\n stderr: spawnResult.stderr.slice(0, context.limits.maxOutputLength),\n timeSeconds: spawnResult.timeSeconds,\n memoryBytes: spawnResult.memoryBytes,\n };\n }\n\n if (spawnResult.timeSeconds > context.limits.buildTimeoutSeconds) {\n return {\n testCaseId: context.testCaseId,\n decisionCode: DecisionCode.BUILD_TIME_LIMIT_EXCEEDED,\n exitStatus,\n stdout: spawnResult.stdout.slice(0, context.limits.maxOutputLength),\n stderr: spawnResult.stderr.slice(0, context.limits.maxOutputLength),\n timeSeconds: spawnResult.timeSeconds,\n memoryBytes: spawnResult.memoryBytes,\n };\n }\n\n if (\n spawnResult.stdout.length > context.limits.maxOutputLength ||\n spawnResult.stderr.length > context.limits.maxOutputLength\n ) {\n return {\n testCaseId: context.testCaseId,\n decisionCode: DecisionCode.BUILD_OUTPUT_SIZE_LIMIT_EXCEEDED,\n exitStatus,\n stdout: spawnResult.stdout.slice(0, context.limits.maxOutputLength),\n stderr: spawnResult.stderr.slice(0, context.limits.maxOutputLength),\n timeSeconds: spawnResult.timeSeconds,\n memoryBytes: spawnResult.memoryBytes,\n };\n }\n\n return;\n}\n\nfunction errorToMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nasync function readCommandTestCases<TTestCase extends BaseCommandTestCase = CommandTestCase>(\n problemDir: string\n): Promise<readonly TTestCase[]> {\n // The default reader yields the base shape; a narrower TTestCase must come from `options.readTestCases`.\n return (await readFileTestCases(path.join(problemDir, 'test_cases'))) as unknown as readonly TTestCase[];\n}\n\n/** The default verdict: a case exposing a string `output` / `fileOutputPath` is judged against it. */\nasync function compareWithExpectedOutputs(context: {\n testCase: BaseCommandTestCase & Partial<CommandTestCase>;\n runResult: CommandRunResult;\n outputFiles: NonNullable<TestCaseResult['outputFiles']>;\n cwd: string;\n}): Promise<Partial<CommandJudgeCaseResult>> {\n const { testCase, runResult, outputFiles, cwd } = context;\n const judgement = await judgeAgainstExpectations({\n stdout: runResult.stdout,\n expectedStdout: typeof testCase.output === 'string' ? testCase.output : undefined,\n fileOutputPath: typeof testCase.fileOutputPath === 'string' ? testCase.fileOutputPath : undefined,\n cwd,\n outputFiles,\n });\n outputFiles.splice(0, outputFiles.length, ...judgement.outputFiles);\n return judgement.matches ? {} : { decisionCode: DecisionCode.WRONG_ANSWER };\n}\n\nfunction runCommand(\n command: readonly [string, ...string[]],\n context: {\n stdin: string;\n cwd: string;\n env: NodeJS.ProcessEnv;\n timeLimitSeconds: number;\n }\n): CommandRunResult {\n const spawnResult = spawnSyncWithTimeout(\n command[0],\n command.slice(1),\n { cwd: context.cwd, encoding: 'utf8', input: context.stdin, env: context.env },\n context.timeLimitSeconds\n );\n\n return {\n stdin: context.stdin,\n stdout: spawnResult.stdout,\n stderr: spawnResult.stderr,\n status: spawnResult.status ?? undefined,\n timeSeconds: spawnResult.timeSeconds,\n memoryBytes: spawnResult.memoryBytes,\n };\n}\n\nfunction evaluateByLimits(context: {\n runResult: CommandRunResult;\n outputFiles: NonNullable<TestCaseResult['outputFiles']>;\n context: CommandJudgeContext;\n}): CommandJudgeCaseResult {\n if (context.runResult.status !== 0) {\n return { decisionCode: DecisionCode.RUNTIME_ERROR, stderr: context.runResult.stderr };\n }\n\n if (context.runResult.timeSeconds > context.context.timeLimitSeconds) {\n return { decisionCode: DecisionCode.TIME_LIMIT_EXCEEDED, stderr: context.runResult.stderr };\n }\n\n if (\n context.runResult.memoryBytes >\n (context.context.problemMarkdownFrontMatter.memoryLimitByte ?? Number.POSITIVE_INFINITY)\n ) {\n return { decisionCode: DecisionCode.MEMORY_LIMIT_EXCEEDED, stderr: context.runResult.stderr };\n }\n\n if (\n context.runResult.outputLimitExceeded ||\n context.runResult.stdout.length > context.context.outputLimitLength ||\n context.runResult.stderr.length > context.context.outputLimitLength\n ) {\n return { decisionCode: DecisionCode.OUTPUT_SIZE_LIMIT_EXCEEDED, stderr: context.runResult.stderr };\n }\n\n const requiredOutputFileCount = context.context.problemMarkdownFrontMatter.requiredOutputFilePaths?.length ?? 0;\n if (context.outputFiles.length < requiredOutputFileCount) {\n return { decisionCode: DecisionCode.MISSING_REQUIRED_OUTPUT_FILE_ERROR };\n }\n\n return { decisionCode: DecisionCode.ACCEPTED };\n}\n"],"mappings":"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"}
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import type { ProblemMarkdownFrontMatter } from '../types/problem.js';
|
|
2
2
|
import type { TestCaseResult } from '../types/testCaseResult.js';
|
|
3
|
+
/** What every command test case needs; a custom `readTestCases` may add any fields of its own. */
|
|
3
4
|
interface BaseCommandTestCase {
|
|
4
5
|
id: string;
|
|
6
|
+
/** Standard input (`test_cases/<id>.in`). */
|
|
5
7
|
input?: string;
|
|
8
|
+
/** Directory copied into the working directory before the run (`test_cases/<id>.fin/`). */
|
|
6
9
|
fileInputPath?: string;
|
|
7
10
|
}
|
|
11
|
+
/** A test case read from `test_cases/` by the default reader. */
|
|
12
|
+
export interface CommandTestCase extends BaseCommandTestCase {
|
|
13
|
+
/** Expected standard output (`test_cases/<id>.out`). */
|
|
14
|
+
output?: string;
|
|
15
|
+
/** Directory of expected output files compared after the run (`test_cases/<id>.fout/`). */
|
|
16
|
+
fileOutputPath?: string;
|
|
17
|
+
}
|
|
8
18
|
type CommandJudgeCaseResult = Pick<TestCaseResult, 'decisionCode' | 'feedbackMarkdown' | 'stderr'>;
|
|
9
19
|
export interface CommandRunResult {
|
|
10
20
|
stdin: string;
|
|
@@ -24,13 +34,14 @@ export interface CommandJudgeLimits {
|
|
|
24
34
|
buildTimeoutSeconds: number;
|
|
25
35
|
maxOutputLength: number;
|
|
26
36
|
}
|
|
27
|
-
export interface CommandJudgePresetOptions<TTestCase extends BaseCommandTestCase =
|
|
37
|
+
export interface CommandJudgePresetOptions<TTestCase extends BaseCommandTestCase = CommandTestCase, TRunResult extends CommandRunResult = CommandRunResult> {
|
|
28
38
|
/**
|
|
29
39
|
* Set to false when the custom runner builds the submitted source itself.
|
|
30
40
|
* Defaults to true.
|
|
31
41
|
*/
|
|
32
42
|
buildSubmission?: boolean;
|
|
33
43
|
limits?: CommandJudgeLimits;
|
|
44
|
+
/** The per-case time limit used only when `problem.md` declares no `timeLimitMs`. */
|
|
34
45
|
runTimeoutSeconds?: number;
|
|
35
46
|
readTestCases?: (problemDir: string) => Promise<readonly TTestCase[]>;
|
|
36
47
|
/**
|
|
@@ -62,20 +73,41 @@ export interface CommandJudgePresetOptions<TTestCase extends BaseCommandTestCase
|
|
|
62
73
|
env: NodeJS.ProcessEnv;
|
|
63
74
|
timeLimitSeconds: number;
|
|
64
75
|
}) => Promise<TRunResult> | TRunResult;
|
|
76
|
+
/**
|
|
77
|
+
* Decides the verdict of a run that passed the limit checks. It replaces the default comparison
|
|
78
|
+
* of `testCase.output` (space-separated tokens) and `testCase.fileOutputPath` (see
|
|
79
|
+
* `compareStdoutAsSpaceSeparatedTokens` and `compareExpectedOutputFiles`, both exported), so
|
|
80
|
+
* call those yourself when the test case ships `.out` / `.fout` expectations to keep.
|
|
81
|
+
* `outputFiles` is the array printed with the result and may be edited in place.
|
|
82
|
+
*/
|
|
65
83
|
test?: (context: {
|
|
66
84
|
testCase: TTestCase;
|
|
67
85
|
runResult: TRunResult;
|
|
68
86
|
outputFiles: NonNullable<TestCaseResult['outputFiles']>;
|
|
87
|
+
/** The submission's working directory, e.g. for `compareExpectedOutputFiles(cwd, testCase.fileOutputPath)`. */
|
|
88
|
+
cwd: string;
|
|
69
89
|
context: CommandJudgeContext;
|
|
70
90
|
}) => Promise<Partial<CommandJudgeCaseResult>> | Partial<CommandJudgeCaseResult> | undefined;
|
|
71
91
|
}
|
|
72
92
|
/**
|
|
73
93
|
* A preset function for judging by executable command.
|
|
74
94
|
*
|
|
95
|
+
* Without options, test cases come from `test_cases/` (`.in`, `.out`, `.fin/`, `.fout/`) and a run
|
|
96
|
+
* within the limits is accepted when its stdout matches `.out` and its files match `.fout/`; a
|
|
97
|
+
* case without either expectation only has to run within the limits.
|
|
75
98
|
* Keep problem-specific logic in `resolveInput` and `test`.
|
|
76
99
|
*
|
|
77
100
|
* @example
|
|
78
|
-
* Create `judge.ts
|
|
101
|
+
* Create `judge.ts` that judges `test_cases/` like the default stdio harness, with the command
|
|
102
|
+
* preset's debug mode:
|
|
103
|
+
* ```ts
|
|
104
|
+
* import { commandJudgePreset } from '@exercode/problem-utils/presets/command';
|
|
105
|
+
*
|
|
106
|
+
* await commandJudgePreset(import.meta.dirname);
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* Create `judge.ts` with an own verdict:
|
|
79
111
|
* ```ts
|
|
80
112
|
* import { commandJudgePreset } from '@exercode/problem-utils/presets/command';
|
|
81
113
|
* import { DecisionCode } from '@exercode/problem-utils';
|
|
@@ -103,5 +135,5 @@ export interface CommandJudgePresetOptions<TTestCase extends BaseCommandTestCase
|
|
|
103
135
|
* bun judge.ts
|
|
104
136
|
* ```
|
|
105
137
|
*/
|
|
106
|
-
export declare function commandJudgePreset<TTestCase extends BaseCommandTestCase =
|
|
138
|
+
export declare function commandJudgePreset<TTestCase extends BaseCommandTestCase = CommandTestCase, TRunResult extends CommandRunResult = CommandRunResult>(problemDir: string, options?: CommandJudgePresetOptions<TTestCase, TRunResult>): Promise<void>;
|
|
107
139
|
export {};
|