From e92bda36cc1f4d9d18fcab0a564fa65a59c07513 Mon Sep 17 00:00:00 2001 From: Silver36-ship-it Date: Mon, 28 Sep 2026 00:16:56 +0100 Subject: [PATCH 1/2] Add server-side Rust WASM sandbox --- BackendAcademy/README.md | 20 ++ BackendAcademy/readme.md | 20 ++ .../src/tasks/dto/create-task.dto.ts | 11 +- .../src/tasks/dto/execute-task.dto.ts | 7 + BackendAcademy/src/tasks/task.controller.ts | 22 +- BackendAcademy/src/tasks/task.module.ts | 5 +- .../src/tasks/wasm-sandbox.service.spec.ts | 66 +++++ .../src/tasks/wasm-sandbox.service.ts | 259 ++++++++++++++++++ 8 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 BackendAcademy/src/tasks/dto/execute-task.dto.ts create mode 100644 BackendAcademy/src/tasks/wasm-sandbox.service.spec.ts create mode 100644 BackendAcademy/src/tasks/wasm-sandbox.service.ts diff --git a/BackendAcademy/README.md b/BackendAcademy/README.md index e6a659c55c..102ada177f 100644 --- a/BackendAcademy/README.md +++ b/BackendAcademy/README.md @@ -15,3 +15,23 @@ pnpm run dev - `test/` — Test files See `app/backend/` for the primary backend implementation and conventions. + +## Rust Task Execution + +Task execution uses `rustc` to compile dependency-free learner programs to +`wasm32-wasip1`, then runs the module with Wasmtime. Install the Rust target +and Wasmtime in the backend environment: + +```bash +rustup target add wasm32-wasip1 +``` + +Executables are resolved from `PATH` by default. Override them with +`RUSTC_BIN` and `WASMTIME_BIN` when needed. The `POST /api/v1/tasks/:id/execute` +endpoint accepts `{ "code": "..." }`; each configured task test case is passed +to the program as stdin and compared with the task's expected output. + +Execution has a 2-second wall-clock timeout, 10-million Wasmtime fuel limit, +16 MiB guest memory limit, 64 KiB combined process-output limit, and no WASI +directory preopens. Compilation and execution artifacts are kept in a unique +temporary directory and removed after the request. diff --git a/BackendAcademy/readme.md b/BackendAcademy/readme.md index e6a659c55c..102ada177f 100644 --- a/BackendAcademy/readme.md +++ b/BackendAcademy/readme.md @@ -15,3 +15,23 @@ pnpm run dev - `test/` — Test files See `app/backend/` for the primary backend implementation and conventions. + +## Rust Task Execution + +Task execution uses `rustc` to compile dependency-free learner programs to +`wasm32-wasip1`, then runs the module with Wasmtime. Install the Rust target +and Wasmtime in the backend environment: + +```bash +rustup target add wasm32-wasip1 +``` + +Executables are resolved from `PATH` by default. Override them with +`RUSTC_BIN` and `WASMTIME_BIN` when needed. The `POST /api/v1/tasks/:id/execute` +endpoint accepts `{ "code": "..." }`; each configured task test case is passed +to the program as stdin and compared with the task's expected output. + +Execution has a 2-second wall-clock timeout, 10-million Wasmtime fuel limit, +16 MiB guest memory limit, 64 KiB combined process-output limit, and no WASI +directory preopens. Compilation and execution artifacts are kept in a unique +temporary directory and removed after the request. diff --git a/BackendAcademy/src/tasks/dto/create-task.dto.ts b/BackendAcademy/src/tasks/dto/create-task.dto.ts index b7f8c6e3b6..3f9b69586d 100644 --- a/BackendAcademy/src/tasks/dto/create-task.dto.ts +++ b/BackendAcademy/src/tasks/dto/create-task.dto.ts @@ -1,4 +1,12 @@ -import { IsString, IsNumber, IsOptional, IsArray, IsEnum, Min } from 'class-validator'; +import { + IsString, + IsNumber, + IsOptional, + IsArray, + IsEnum, + Min, + ArrayMaxSize, +} from 'class-validator'; import { TaskDifficulty } from '../interfaces/task-difficulty.enum'; export class CreateTaskDto { @@ -16,6 +24,7 @@ export class CreateTaskDto { @IsOptional() @IsArray() + @ArrayMaxSize(20) @IsString({ each: true }) testCases?: string[]; diff --git a/BackendAcademy/src/tasks/dto/execute-task.dto.ts b/BackendAcademy/src/tasks/dto/execute-task.dto.ts new file mode 100644 index 0000000000..ce7319a70a --- /dev/null +++ b/BackendAcademy/src/tasks/dto/execute-task.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MaxLength } from 'class-validator'; + +export class ExecuteTaskDto { + @IsString() + @MaxLength(20_000) + code: string; +} diff --git a/BackendAcademy/src/tasks/task.controller.ts b/BackendAcademy/src/tasks/task.controller.ts index 1bd237288f..17e82fd2cc 100644 --- a/BackendAcademy/src/tasks/task.controller.ts +++ b/BackendAcademy/src/tasks/task.controller.ts @@ -9,14 +9,29 @@ import { ParseUUIDPipe, HttpCode, HttpStatus, + NotFoundException, } from '@nestjs/common'; import { TaskService } from './task.service'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; +import { ExecuteTaskDto } from './dto/execute-task.dto'; +import { WasmSandboxService } from './wasm-sandbox.service'; @Controller('tasks') export class TaskController { - constructor(private readonly taskService: TaskService) {} + constructor( + private readonly taskService: TaskService, + private readonly wasmSandboxService: WasmSandboxService, + ) {} + + @Post(':id/execute') + async execute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ExecuteTaskDto) { + const task = await this.taskService.findById(id); + if (!task || !task.isActive) { + throw new NotFoundException('Task not found'); + } + return this.wasmSandboxService.execute(task, dto.code); + } @Post() async create(@Body() dto: CreateTaskDto) { @@ -39,10 +54,7 @@ export class TaskController { } @Put(':id') - async update( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: UpdateTaskDto, - ) { + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTaskDto) { return this.taskService.update(id, dto); } diff --git a/BackendAcademy/src/tasks/task.module.ts b/BackendAcademy/src/tasks/task.module.ts index 8a379597db..78982e72eb 100644 --- a/BackendAcademy/src/tasks/task.module.ts +++ b/BackendAcademy/src/tasks/task.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { TaskController } from './task.controller'; import { TaskService } from './task.service'; +import { WasmSandboxService } from './wasm-sandbox.service'; @Module({ controllers: [TaskController], - providers: [TaskService], - exports: [TaskService], + providers: [TaskService, WasmSandboxService], + exports: [TaskService, WasmSandboxService], }) export class TaskModule {} diff --git a/BackendAcademy/src/tasks/wasm-sandbox.service.spec.ts b/BackendAcademy/src/tasks/wasm-sandbox.service.spec.ts new file mode 100644 index 0000000000..aec38df682 --- /dev/null +++ b/BackendAcademy/src/tasks/wasm-sandbox.service.spec.ts @@ -0,0 +1,66 @@ +import { BadRequestException } from '@nestjs/common'; +import { TaskEntity } from './task.entity'; +import { WasmSandboxService } from './wasm-sandbox.service'; + +describe('WasmSandboxService', () => { + let service: WasmSandboxService; + + beforeEach(() => { + service = new WasmSandboxService(); + }); + + it('compiles once and returns each case result', async () => { + const runProcess = jest + .spyOn(service as any, 'runProcess') + .mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + durationMs: 12, + timedOut: false, + outputExceeded: false, + }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'hello\r\n', + stderr: '', + durationMs: 8, + timedOut: false, + outputExceeded: false, + }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'wrong', + stderr: '', + durationMs: 11, + timedOut: false, + outputExceeded: false, + }); + + const result = await service.execute( + { + testCases: ['first input', 'second input'], + expectedOutput: 'hello', + } as TaskEntity, + 'fn main() {}', + ); + + expect(result).toEqual({ + compiled: true, + passed: false, + results: [ + { index: 0, passed: true, stdout: 'hello\r\n', durationMs: 8 }, + { index: 1, passed: false, stdout: 'wrong', durationMs: 11 }, + ], + }); + expect(runProcess).toHaveBeenCalledTimes(3); + expect(runProcess.mock.calls[1][2]).toBe('first input'); + expect(runProcess.mock.calls[2][2]).toBe('second input'); + }); + + it('rejects tasks without test cases', async () => { + await expect( + service.execute({ testCases: [] } as TaskEntity, 'fn main() {}'), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/BackendAcademy/src/tasks/wasm-sandbox.service.ts b/BackendAcademy/src/tasks/wasm-sandbox.service.ts new file mode 100644 index 0000000000..0226edb706 --- /dev/null +++ b/BackendAcademy/src/tasks/wasm-sandbox.service.ts @@ -0,0 +1,259 @@ +import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { spawn } from 'child_process'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { TaskEntity } from './task.entity'; + +const COMPILE_TIMEOUT_MS = 10_000; +const EXECUTION_TIMEOUT_MS = 2_000; +const MAX_OUTPUT_BYTES = 64 * 1024; +const MAX_TEST_CASES = 20; +const MAX_MEMORY_BYTES = 16 * 1024 * 1024; +const FUEL_LIMIT = 10_000_000; + +interface ProcessResult { + exitCode: number | null; + stdout: string; + stderr: string; + durationMs: number; + timedOut: boolean; + outputExceeded: boolean; +} + +export interface TestCaseResult { + index: number; + passed: boolean; + stdout: string; + durationMs: number; + error?: string; +} + +export interface TaskExecutionResult { + compiled: boolean; + passed: boolean; + results: TestCaseResult[]; + compileError?: string; +} + +@Injectable() +export class WasmSandboxService { + async execute(task: TaskEntity, code: string): Promise { + if (!task.testCases.length) { + throw new BadRequestException('Task has no test cases configured'); + } + if (task.testCases.length > MAX_TEST_CASES) { + throw new BadRequestException(`A task may have at most ${MAX_TEST_CASES} test cases`); + } + + const workspace = await mkdtemp(join(tmpdir(), 'rustacademy-wasm-')); + const sourcePath = join(workspace, 'main.rs'); + const wasmPath = join(workspace, 'main.wasm'); + + try { + await writeFile(sourcePath, code, { encoding: 'utf8', flag: 'wx' }); + let compilation: ProcessResult; + + try { + compilation = await this.runProcess( + process.env.RUSTC_BIN || 'rustc', + [ + '--edition=2021', + '--target=wasm32-wasip1', + '--crate-type=bin', + '-o', + wasmPath, + sourcePath, + ], + '', + workspace, + COMPILE_TIMEOUT_MS, + this.compilerEnvironment(workspace), + ); + } catch { + throw new ServiceUnavailableException('Rust WASM compiler is unavailable'); + } + + if (compilation.exitCode !== 0 || compilation.timedOut || compilation.outputExceeded) { + return { + compiled: false, + passed: false, + results: [], + compileError: compilation.timedOut + ? 'Compilation timed out' + : compilation.outputExceeded + ? 'Compiler output exceeded the limit' + : compilation.stderr || 'Rust compilation failed', + }; + } + + const results: TestCaseResult[] = []; + for (const [index, input] of task.testCases.entries()) { + results.push( + await this.runTestCase(wasmPath, workspace, input, task.expectedOutput, index), + ); + } + + return { + compiled: true, + passed: results.every((result) => result.passed), + results, + }; + } finally { + await rm(workspace, { recursive: true, force: true }); + } + } + + private async runTestCase( + wasmPath: string, + workspace: string, + input: string, + expectedOutput: string, + index: number, + ): Promise { + let execution: ProcessResult; + + try { + execution = await this.runProcess( + process.env.WASMTIME_BIN || 'wasmtime', + [ + 'run', + '--fuel', + String(FUEL_LIMIT), + '--max-memory-size', + String(MAX_MEMORY_BYTES), + wasmPath, + ], + input, + workspace, + EXECUTION_TIMEOUT_MS, + this.sandboxEnvironment(workspace), + ); + } catch { + throw new ServiceUnavailableException('Wasmtime runtime is unavailable'); + } + + const error = execution.timedOut + ? 'Execution timed out' + : execution.outputExceeded + ? 'Program output exceeded the limit' + : execution.exitCode !== 0 + ? execution.stderr || `Program exited with code ${execution.exitCode}` + : undefined; + const stdout = execution.stdout; + + return { + index, + passed: !error && this.normalizeOutput(stdout) === this.normalizeOutput(expectedOutput), + stdout, + durationMs: execution.durationMs, + ...(error ? { error } : {}), + }; + } + + private runProcess( + command: string, + args: string[], + input: string, + cwd: string, + timeoutMs: number, + env: NodeJS.ProcessEnv, + ): Promise { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + let stdout = ''; + let stderr = ''; + let outputBytes = 0; + let timedOut = false; + let outputExceeded = false; + let settled = false; + + let child; + try { + child = spawn(command, args, { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + } catch (error) { + reject(error); + return; + } + + const timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, timeoutMs); + + const collect = (target: 'stdout' | 'stderr') => (chunk: Buffer) => { + outputBytes += chunk.length; + if (outputBytes > MAX_OUTPUT_BYTES) { + outputExceeded = true; + child.kill('SIGKILL'); + return; + } + if (target === 'stdout') stdout += chunk.toString('utf8'); + else stderr += chunk.toString('utf8'); + }; + + child.stdout.on('data', collect('stdout')); + child.stderr.on('data', collect('stderr')); + child.once('error', (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(error); + }); + child.once('close', (exitCode) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve({ + exitCode, + stdout, + stderr, + durationMs: Date.now() - startedAt, + timedOut, + outputExceeded, + }); + }); + child.stdin.end(input); + }); + } + + private sandboxEnvironment(workspace: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: workspace, + TMPDIR: workspace, + TEMP: workspace, + TMP: workspace, + }; + + if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; + if (process.env.WINDIR) env.WINDIR = process.env.WINDIR; + return env; + } + + private compilerEnvironment(workspace: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + RUSTUP_HOME: + process.env.RUSTUP_HOME || + join(process.env.USERPROFILE || process.env.HOME || tmpdir(), '.rustup'), + HOME: workspace, + TMPDIR: workspace, + TEMP: workspace, + TMP: workspace, + }; + + if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; + if (process.env.WINDIR) env.WINDIR = process.env.WINDIR; + return env; + } + + private normalizeOutput(output: string): string { + return output.replace(/\r\n/g, '\n').replace(/\n+$/, ''); + } +} From 34621574cf40a6f83b6d08443ba5179a9e3ce1d1 Mon Sep 17 00:00:00 2001 From: Silver36-ship-it Date: Mon, 28 Sep 2026 00:44:34 +0100 Subject: [PATCH 2/2] Fix sandbox test input framing --- BackendAcademy/src/sandbox/sandbox.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/BackendAcademy/src/sandbox/sandbox.service.ts b/BackendAcademy/src/sandbox/sandbox.service.ts index 02e8afdbdc..5dfb5f6e81 100644 --- a/BackendAcademy/src/sandbox/sandbox.service.ts +++ b/BackendAcademy/src/sandbox/sandbox.service.ts @@ -144,6 +144,7 @@ export class SandboxService { const runnerCommand = [ "IFS= read -r source_len", 'head -c "$source_len" > /tmp/main.rs', + "IFS= read -r separator", "IFS= read -r input_len", 'head -c "$input_len" > /tmp/stdin', `timeout -k 1s ${COMPILE_TIMEOUT_SECONDS}s rustc --edition=2021 --target=wasm32-wasip1 --crate-type=bin -o /tmp/main.wasm /tmp/main.rs`,