Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions backend/src/routes/playground.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
});

Expand Down
188 changes: 157 additions & 31 deletions backend/src/services/rust-validation.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,196 @@
// @ts-nocheck
interface ValidationDiagnostic {
export interface ValidationDiagnostic {
line: number;
column: number;
severity: 'error' | 'warning';
message: string;
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<Record<string, string>> = Object.freeze({
'(': ')',
'[': ']',
'{': '}',
});
const CLOSING_DELIMITERS = new Set([')', ']', '}']);
const TIME_CHECK_INTERVAL = 256;

type ValidationClock = () => number;

function terminalResult(
status: Extract<ValidationStatus, 'rejected' | 'timed_out'>,
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<ValidationResult> {
/**
* 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<ValidationResult> {
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<string, string> = { '(': ')', '[': ']', '{': '}' };
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',
Expand All @@ -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,
};
}
Expand Down
95 changes: 94 additions & 1 deletion backend/tests/playground.validation.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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');
});
Expand All @@ -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());
Expand All @@ -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();
});
});