From f1851d3b0c25cdcca29506ca319d19da11f82ecb Mon Sep 17 00:00:00 2001 From: Grantfox Agent <160465301+emirykl@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:09:21 +0300 Subject: [PATCH] fix: bound Rust playground validation --- backend/src/routes/playground.routes.ts | 14 +- backend/src/services/rust-validation.ts | 188 ++++++++++++++++---- backend/tests/playground.validation.test.ts | 95 +++++++++- 3 files changed, 263 insertions(+), 34 deletions(-) diff --git a/backend/src/routes/playground.routes.ts b/backend/src/routes/playground.routes.ts index 6314070f..0d3e57e4 100644 --- a/backend/src/routes/playground.routes.ts +++ b/backend/src/routes/playground.routes.ts @@ -13,9 +13,19 @@ router.post('/validate', async (req: Request, res: Response) => { } const result = await RustValidationService.validateCode(code); + if (result.status === 'rejected') { + res.status(413).json(result); + return; + } + + if (result.status === 'timed_out') { + res.status(422).json(result); + return; + } + res.json(result); - } catch (error) { - res.status(500).json({ error: 'Playground validation failed', details: String(error) }); + } catch { + res.status(500).json({ error: 'Playground validation failed' }); } }); diff --git a/backend/src/services/rust-validation.ts b/backend/src/services/rust-validation.ts index 95810136..a93d4faa 100644 --- a/backend/src/services/rust-validation.ts +++ b/backend/src/services/rust-validation.ts @@ -1,5 +1,4 @@ -// @ts-nocheck -interface ValidationDiagnostic { +export interface ValidationDiagnostic { line: number; column: number; severity: 'error' | 'warning'; @@ -7,70 +6,191 @@ interface ValidationDiagnostic { code: string; } -interface ValidationResult { +export type ValidationStatus = 'valid' | 'invalid' | 'rejected' | 'timed_out'; + +export interface ValidationResult { isValid: boolean; + status: ValidationStatus; diagnostics: ValidationDiagnostic[]; } +export const RUST_VALIDATION_LIMITS = Object.freeze({ + maxInputBytes: 64 * 1024, + timeoutMs: 100, + maxDiagnostics: 100, +}); + +const DELIMITER_PAIRS: Readonly> = Object.freeze({ + '(': ')', + '[': ']', + '{': '}', +}); +const CLOSING_DELIMITERS = new Set([')', ']', '}']); +const TIME_CHECK_INTERVAL = 256; + +type ValidationClock = () => number; + +function terminalResult( + status: Extract, + diagnostic: ValidationDiagnostic +): ValidationResult { + return { + isValid: false, + status, + diagnostics: [diagnostic], + }; +} + // Automated Code Validation Pipeline for Rust Playground export class RustValidationService { - static async validateCode(code: string): Promise { + /** + * Performs lightweight validation without invoking a compiler. + * + * The optional clock is a deterministic testing seam. Production callers + * should use the monotonic default. + */ + static async validateCode( + code: string, + clock: ValidationClock = () => performance.now() + ): Promise { + if ( + code.length > RUST_VALIDATION_LIMITS.maxInputBytes || + Buffer.byteLength(code, 'utf8') > RUST_VALIDATION_LIMITS.maxInputBytes + ) { + return terminalResult('rejected', { + line: 1, + column: 1, + severity: 'error', + message: `Source code must not exceed ${RUST_VALIDATION_LIMITS.maxInputBytes} UTF-8 bytes`, + code: 'input-too-large', + }); + } + const diagnostics: ValidationDiagnostic[] = []; const stack: Array<{ char: string; line: number; column: number }> = []; - const pairs: Record = { '(': ')', '[': ']', '{': '}' }; + const startedAt = clock(); + let inspectedCharacters = 0; + let diagnosticLimitReached = false; + + const addDiagnostic = (diagnostic: ValidationDiagnostic): boolean => { + if (diagnostics.length < RUST_VALIDATION_LIMITS.maxDiagnostics - 1) { + diagnostics.push(diagnostic); + return true; + } + + diagnostics.push({ + line: diagnostic.line, + column: diagnostic.column, + severity: 'error', + message: `Validation stopped after ${RUST_VALIDATION_LIMITS.maxDiagnostics} diagnostics`, + code: 'diagnostic-limit-exceeded', + }); + diagnosticLimitReached = true; + return false; + }; + + const hasTimedOut = (): boolean => + clock() - startedAt >= RUST_VALIDATION_LIMITS.timeoutMs; + + const timeoutResult = (line: number, column: number): ValidationResult => + terminalResult('timed_out', { + line, + column, + severity: 'error', + message: `Validation exceeded the ${RUST_VALIDATION_LIMITS.timeoutMs} ms time limit`, + code: 'validation-timeout', + }); const lines = code.split(/\r?\n/); - lines.forEach((line, index) => { + validationLoop: for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ''; const lineNumber = index + 1; + if (index % TIME_CHECK_INTERVAL === 0 && hasTimedOut()) { + return timeoutResult(lineNumber, 1); + } + for (let columnIndex = 0; columnIndex < line.length; columnIndex += 1) { - const char = line[columnIndex]; - if (Object.prototype.hasOwnProperty.call(pairs, char)) { + if ( + inspectedCharacters % TIME_CHECK_INTERVAL === 0 && + hasTimedOut() + ) { + return timeoutResult(lineNumber, columnIndex + 1); + } + inspectedCharacters += 1; + + const char = line[columnIndex] ?? ''; + if (Object.prototype.hasOwnProperty.call(DELIMITER_PAIRS, char)) { stack.push({ char, line: lineNumber, column: columnIndex + 1 }); continue; } - if (char === ')' || char === ']' || char === '}') { + if (CLOSING_DELIMITERS.has(char)) { const opener = stack.pop(); if (!opener) { - diagnostics.push({ - line: lineNumber, - column: columnIndex + 1, - severity: 'error', - message: `Unexpected closing token ${char}`, - code: 'unexpected-token', - }); + if ( + !addDiagnostic({ + line: lineNumber, + column: columnIndex + 1, + severity: 'error', + message: `Unexpected closing token ${char}`, + code: 'unexpected-token', + }) + ) { + break validationLoop; + } continue; } - const expected = pairs[opener.char]; - if (expected !== char) { - diagnostics.push({ + const expected = DELIMITER_PAIRS[opener.char]; + if ( + expected !== char && + !addDiagnostic({ line: lineNumber, column: columnIndex + 1, severity: 'error', message: `Expected ${expected} to close ${opener.char} from line ${opener.line}`, code: 'mismatched-delimiter', - }); + }) + ) { + break validationLoop; } } } - }); + } + + while (!diagnosticLimitReached && stack.length > 0) { + if (hasTimedOut()) { + const opener = stack[stack.length - 1]; + return timeoutResult(opener?.line ?? 1, opener?.column ?? 1); + } - while (stack.length > 0) { const opener = stack.pop(); if (!opener) continue; - diagnostics.push({ - line: opener.line, - column: opener.column, - severity: 'error', - message: `Unclosed block or parenthesis starting at ${opener.char}`, - code: 'unclosed-block', - }); + if ( + !addDiagnostic({ + line: opener.line, + column: opener.column, + severity: 'error', + message: `Unclosed block or parenthesis starting at ${opener.char}`, + code: 'unclosed-block', + }) + ) { + break; + } } - if (/fn\s+\w+\s*\([^)]*$/.test(code) && diagnostics.length === 0) { - diagnostics.push({ + if (hasTimedOut()) { + const lastLine = lines[lines.length - 1] ?? ''; + return timeoutResult(lines.length, lastLine.length + 1); + } + + if ( + !diagnosticLimitReached && + /fn\s+\w+\s*\([^)]*$/.test(code) && + diagnostics.length === 0 + ) { + addDiagnostic({ line: 1, column: 1, severity: 'error', @@ -79,8 +199,14 @@ export class RustValidationService { }); } + if (hasTimedOut()) { + const lastLine = lines[lines.length - 1] ?? ''; + return timeoutResult(lines.length, lastLine.length + 1); + } + return { isValid: diagnostics.length === 0, + status: diagnostics.length === 0 ? 'valid' : 'invalid', diagnostics, }; } diff --git a/backend/tests/playground.validation.test.ts b/backend/tests/playground.validation.test.ts index 593231d2..9848c821 100644 --- a/backend/tests/playground.validation.test.ts +++ b/backend/tests/playground.validation.test.ts @@ -1,6 +1,9 @@ import express from 'express'; import request from 'supertest'; -import { RustValidationService } from '../src/services/rust-validation.js'; +import { + RUST_VALIDATION_LIMITS, + RustValidationService, +} from '../src/services/rust-validation.js'; import playgroundRouter from '../src/routes/playground.routes.js'; describe('Playground validation', () => { @@ -15,6 +18,7 @@ impl BrokenContract { `); expect(result.isValid).toBe(false); + expect(result.status).toBe('invalid'); expect(result.diagnostics.length).toBeGreaterThan(0); expect(result.diagnostics[0].message).toContain('Unclosed block'); }); @@ -34,9 +38,62 @@ impl HelloContract { }`); expect(result.isValid).toBe(true); + expect(result.status).toBe('valid'); expect(result.diagnostics).toEqual([]); }); + it('accepts source at the UTF-8 byte limit', async () => { + const code = 'a'.repeat(RUST_VALIDATION_LIMITS.maxInputBytes); + + const result = await RustValidationService.validateCode(code); + + expect(result).toMatchObject({ + isValid: true, + status: 'valid', + diagnostics: [], + }); + }); + + it('rejects source over the UTF-8 byte limit without echoing it', async () => { + const code = '🚀'.repeat(RUST_VALIDATION_LIMITS.maxInputBytes / 2); + + const result = await RustValidationService.validateCode(code); + + expect(result).toMatchObject({ + isValid: false, + status: 'rejected', + diagnostics: [{ code: 'input-too-large', severity: 'error' }], + }); + expect(JSON.stringify(result)).not.toContain(code); + }); + + it('stops after the configured diagnostic limit', async () => { + const result = await RustValidationService.validateCode( + ')'.repeat(RUST_VALIDATION_LIMITS.maxDiagnostics + 10) + ); + + expect(result.isValid).toBe(false); + expect(result.status).toBe('invalid'); + expect(result.diagnostics).toHaveLength(RUST_VALIDATION_LIMITS.maxDiagnostics); + expect(result.diagnostics.at(-1)?.code).toBe('diagnostic-limit-exceeded'); + }); + + it('returns a structured timeout diagnostic when the deadline is exceeded', async () => { + let clockCalls = 0; + const clock = () => { + clockCalls += 1; + return clockCalls < 3 ? 0 : RUST_VALIDATION_LIMITS.timeoutMs; + }; + + const result = await RustValidationService.validateCode('\n'.repeat(512), clock); + + expect(result).toMatchObject({ + isValid: false, + status: 'timed_out', + diagnostics: [{ code: 'validation-timeout', severity: 'error' }], + }); + }); + it('exposes diagnostics through the API', async () => { const app = express(); app.use(express.json()); @@ -48,6 +105,42 @@ impl HelloContract { .expect(200); expect(response.body.isValid).toBe(false); + expect(response.body.status).toBe('invalid'); expect(response.body.diagnostics[0].message).toContain('Unclosed'); }); + + it('rejects oversized API input with a structured 413 response', async () => { + const app = express(); + app.use(express.json({ limit: '1mb' })); + app.use('/api/v1/playground', playgroundRouter); + + const response = await request(app) + .post('/api/v1/playground/validate') + .send({ code: 'a'.repeat(RUST_VALIDATION_LIMITS.maxInputBytes + 1) }) + .expect(413); + + expect(response.body).toMatchObject({ + isValid: false, + status: 'rejected', + diagnostics: [{ code: 'input-too-large' }], + }); + }); + + it('does not expose internal exception details through the API', async () => { + const app = express(); + app.use(express.json()); + app.use('/api/v1/playground', playgroundRouter); + const validation = jest + .spyOn(RustValidationService, 'validateCode') + .mockRejectedValueOnce(new Error('private source detail')); + + const response = await request(app) + .post('/api/v1/playground/validate') + .send({ code: 'fn main() {}' }) + .expect(500); + + expect(response.body).toEqual({ error: 'Playground validation failed' }); + expect(JSON.stringify(response.body)).not.toContain('private source detail'); + validation.mockRestore(); + }); });