diff --git a/apps/backend/README.md b/apps/backend/README.md
index 8a9312c..19bfb8a 100644
--- a/apps/backend/README.md
+++ b/apps/backend/README.md
@@ -92,8 +92,9 @@ The server will start on port 3000 by default (configurable via environment vari
| Endpoint | Method | Description | Request Body | Response |
|----------|--------|-------------|--------------|----------|
| `/api/compile` | POST | Compiles Rust code to WASM | `{ code: string }` | `{ success: boolean, output: string, error?: string }` |
-| `/api/test` | POST | Runs tests for Rust code | `{ code: string }` | `{ success: boolean, output: string, error?: string }` |
| `/api/health` | GET | Server health check | None | `{ status: "ok" }` |
+| `/api/test` | POST | Runs tests for Rust code | `{ code: string }` | `{ success: boolean, output: string, error?: string }` |
+| `/api/test-filemanager` | POST | Test fileManager utilities | `{ baseName?: string, rustCode?: string }` | `{ success: boolean, sanitizedName: string, tempDir: string, message: string }` |
## Security Measures
@@ -130,7 +131,7 @@ backend/
│ │ ├── compile.controller.ts
│ │ └── test.controller.ts
│ ├── utils/
-│ │ ├── file.utils.ts
+│ │ ├── fileManager.ts
│ │ └── process.utils.ts
│ ├── services/
│ │ ├── compilation.service.ts
diff --git a/apps/backend/bun.lock b/apps/backend/bun.lock
index 0635f46..167e10a 100644
--- a/apps/backend/bun.lock
+++ b/apps/backend/bun.lock
@@ -24,6 +24,9 @@
"typescript": "5.8.3",
"typescript-eslint": "^8.30.0",
},
+ "peerDependencies": {
+ "typescript": "^5.8.3",
+ },
},
},
"packages": {
diff --git a/apps/backend/package.json b/apps/backend/package.json
index 8b26c70..609b209 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -28,7 +28,7 @@
"private": true,
"scripts": {
"dev": "bun run --watch src/index.ts",
- "build": "bun build src/index.ts --outdir ./dist",
+ "build": "bun build src/index.ts --outdir ./dist --target node",
"lint": "bun eslint src/**/*.ts",
"format": "bun prettier --write 'src/**/*.ts'"
},
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index f9426d7..663963d 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -1,6 +1,48 @@
import express from 'express';
+import { setupProject, getSanitizedDirName, createRustProject } from './utils/fileManager';
+
const app = express();
+app.use(express.json());
+
app.get('/', (_, res) =>
res.send('Hello from Backend!' + '
' + 'The best online soroban compiler is coming...')
);
+
+// Test endpoint for fileManager functionality
+app.post('/api/test-filemanager', async (req, res) => {
+ try {
+ const {
+ baseName = 'test-project',
+ rustCode = 'pub fn hello() -> &\'static str { "Hello, Soroban!" }',
+ } = req.body;
+
+ // Test sanitization
+ const sanitized = getSanitizedDirName(baseName);
+
+ // Test project setup
+ const project = await setupProject({ baseName });
+
+ // Test Rust project creation
+ await createRustProject(project.tempDir, rustCode);
+
+ // Success response
+ const response = {
+ success: true,
+ sanitizedName: sanitized,
+ tempDir: project.tempDir,
+ message: 'FileManager test completed successfully - Rust project created and cleaned up',
+ };
+
+ // Cleanup
+ await project.cleanup();
+
+ res.json(response);
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+});
+
app.listen(3000, () => console.log('Server on http://localhost:3000'));
diff --git a/apps/backend/src/utils/fileManager.test.ts b/apps/backend/src/utils/fileManager.test.ts
new file mode 100644
index 0000000..c4d4899
--- /dev/null
+++ b/apps/backend/src/utils/fileManager.test.ts
@@ -0,0 +1,319 @@
+import { describe, it, expect, afterEach } from 'bun:test';
+import { promises as fs } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import {
+ setupProject,
+ cleanupProject,
+ getSanitizedDirName,
+ createRustProject,
+} from './fileManager';
+
+describe('fileManager Security Tests', () => {
+ let testDirs: string[] = [];
+
+ afterEach(async () => {
+ // Clean up any test directories
+ for (const dir of testDirs) {
+ try {
+ await fs.rm(dir, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+ testDirs = [];
+ });
+
+ describe('getSanitizedDirName', () => {
+ it('should sanitize path traversal attempts', () => {
+ expect(getSanitizedDirName('../malicious')).toBe('malicious');
+ expect(getSanitizedDirName('../../etc/passwd')).toBe('etc_passwd');
+ expect(getSanitizedDirName('../../../root')).toBe('root');
+ expect(getSanitizedDirName('..\\..\\windows')).toBe('windows');
+ });
+
+ it('should handle Windows reserved filenames', () => {
+ const windowsReserved = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'LPT1', 'LPT2'];
+
+ for (const reserved of windowsReserved) {
+ const result = getSanitizedDirName(reserved);
+ expect(result).not.toBe(reserved.toLowerCase());
+ expect(result).not.toBe(reserved.toUpperCase());
+ // Should return 'project' as fallback for reserved names
+ expect(result).toBe('project');
+ }
+ });
+
+ it('should handle dangerous characters', () => {
+ const dangerousChars = '<>:"/\\\\|?*';
+ const result = getSanitizedDirName(`test${dangerousChars}name`);
+
+ // Should not contain any of the dangerous characters
+ for (const char of dangerousChars) {
+ expect(result).not.toContain(char);
+ }
+
+ expect(result).toContain('test');
+ expect(result).toContain('name');
+ });
+
+ it('should handle unicode and emoji', () => {
+ expect(getSanitizedDirName('test🚀project')).toBeTruthy();
+ expect(getSanitizedDirName('tëst-prøjéct')).toBeTruthy();
+ expect(getSanitizedDirName('测试项目')).toBeTruthy();
+ });
+
+ it('should handle long filenames', () => {
+ const longName = 'a'.repeat(300);
+ const result = getSanitizedDirName(longName);
+
+ expect(result.length).toBeLessThanOrEqual(255);
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should handle empty and whitespace inputs', () => {
+ expect(getSanitizedDirName('')).toBe('');
+ expect(getSanitizedDirName(' ')).toBe('');
+ expect(getSanitizedDirName('\t\n\r')).toBe('');
+ });
+
+ it('should handle null and undefined inputs', () => {
+ expect(getSanitizedDirName(null as unknown as string)).toBe('');
+ expect(getSanitizedDirName(undefined as unknown as string)).toBe('');
+ expect(getSanitizedDirName(123 as unknown as string)).toBe('');
+ });
+
+ it('should preserve valid directory names', () => {
+ expect(getSanitizedDirName('valid-project')).toBe('valid-project');
+ expect(getSanitizedDirName('my_contract_v1')).toBe('my_contract_v1');
+ expect(getSanitizedDirName('Project123')).toBe('Project123');
+ });
+ });
+
+ describe('setupProject', () => {
+ it('should create unique directories with sanitized names', async () => {
+ const project1 = await setupProject({ baseName: '../malicious' });
+ const project2 = await setupProject({ baseName: '../malicious' });
+
+ testDirs.push(project1.tempDir, project2.tempDir);
+
+ // Should create different directories even with same base name
+ expect(project1.tempDir).not.toBe(project2.tempDir);
+
+ // Should not contain path traversal
+ expect(project1.tempDir).not.toContain('../');
+ expect(project2.tempDir).not.toContain('../');
+
+ // Should be in system temp directory
+ expect(project1.tempDir.startsWith(tmpdir())).toBe(true);
+ expect(project2.tempDir.startsWith(tmpdir())).toBe(true);
+
+ // Directories should exist
+ const stats1 = await fs.stat(project1.tempDir);
+ const stats2 = await fs.stat(project2.tempDir);
+ expect(stats1.isDirectory()).toBe(true);
+ expect(stats2.isDirectory()).toBe(true);
+
+ // Cleanup
+ await project1.cleanup();
+ await project2.cleanup();
+ });
+
+ it('should handle Windows reserved names safely', async () => {
+ const project = await setupProject({ baseName: 'CON' });
+ testDirs.push(project.tempDir);
+
+ // Should not contain 'CON' as directory name
+ const dirName = project.tempDir.split(/[/\\]/).pop() || '';
+ expect(dirName.toLowerCase()).not.toBe('con');
+
+ // Should still create a valid directory
+ const stats = await fs.stat(project.tempDir);
+ expect(stats.isDirectory()).toBe(true);
+
+ await project.cleanup();
+ });
+
+ it('should create directories with fallback names for empty inputs', async () => {
+ const project = await setupProject({ baseName: '' });
+ testDirs.push(project.tempDir);
+
+ // Should create a directory even with empty base name
+ const stats = await fs.stat(project.tempDir);
+ expect(stats.isDirectory()).toBe(true);
+
+ // Directory name should contain 'project' as fallback
+ const dirName = project.tempDir.split(/[/\\]/).pop() || '';
+ expect(dirName).toContain('project');
+
+ await project.cleanup();
+ });
+
+ it('should prevent directory creation outside temp folder with custom tempRoot', async () => {
+ // This should work - using a subdirectory of temp
+ const customTemp = join(tmpdir(), 'custom-temp');
+ await fs.mkdir(customTemp, { recursive: true });
+ testDirs.push(customTemp);
+
+ const project = await setupProject({
+ baseName: 'test',
+ tempRoot: customTemp,
+ });
+ testDirs.push(project.tempDir);
+
+ expect(project.tempDir.startsWith(customTemp)).toBe(true);
+
+ const stats = await fs.stat(project.tempDir);
+ expect(stats.isDirectory()).toBe(true);
+
+ await project.cleanup();
+ });
+ });
+
+ describe('cleanupProject', () => {
+ it('should safely remove project directories', async () => {
+ const project = await setupProject({ baseName: 'cleanup-test' });
+
+ // Verify directory exists
+ const stats = await fs.stat(project.tempDir);
+ expect(stats.isDirectory()).toBe(true);
+
+ // Create some files in the directory
+ await fs.writeFile(join(project.tempDir, 'test.txt'), 'test content');
+ await fs.mkdir(join(project.tempDir, 'subdir'));
+ await fs.writeFile(join(project.tempDir, 'subdir', 'nested.txt'), 'nested content');
+
+ // Cleanup should remove everything
+ await cleanupProject(project.tempDir);
+
+ // Directory should no longer exist
+ await expect(fs.stat(project.tempDir)).rejects.toThrow();
+ });
+
+ it('should refuse to clean directories outside temp folder', async () => {
+ // Try to clean a directory outside temp
+ const maliciousPath = '/etc/passwd';
+
+ await expect(cleanupProject(maliciousPath)).rejects.toThrow(
+ 'Refusing to clean directory outside temp folder'
+ );
+ });
+
+ it('should handle non-existent directories gracefully', async () => {
+ const nonExistentPath = join(tmpdir(), 'non-existent-dir-12345');
+
+ // Should not throw error for non-existent directory
+ await expect(cleanupProject(nonExistentPath)).resolves.toBeUndefined();
+ });
+
+ it('should validate input parameters', async () => {
+ await expect(cleanupProject('')).rejects.toThrow('Invalid tempDir provided');
+ await expect(cleanupProject(null as unknown as string)).rejects.toThrow(
+ 'Invalid tempDir provided'
+ );
+ await expect(cleanupProject(undefined as unknown as string)).rejects.toThrow(
+ 'Invalid tempDir provided'
+ );
+ });
+ });
+
+ describe('createRustProject', () => {
+ it('should create valid Rust project structure', async () => {
+ const project = await setupProject({ baseName: 'rust-test' });
+ testDirs.push(project.tempDir);
+
+ const rustCode = `
+use soroban_sdk::{contract, contractimpl};
+
+#[contract]
+pub struct HelloContract;
+
+#[contractimpl]
+impl HelloContract {
+ pub fn hello() -> &'static str {
+ "Hello, Soroban!"
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn test_hello() {
+ assert_eq!(HelloContract::hello(), "Hello, Soroban!");
+ }
+}
+ `.trim();
+
+ await createRustProject(project.tempDir, rustCode);
+
+ // Verify Cargo.toml exists and contains expected content
+ const cargoToml = await fs.readFile(join(project.tempDir, 'Cargo.toml'), 'utf8');
+ expect(cargoToml).toContain('[package]');
+ expect(cargoToml).toContain('soroban-sdk');
+ expect(cargoToml).toContain('crate-type = ["cdylib"]');
+
+ // Verify lib.rs exists with the provided code
+ const libRs = await fs.readFile(join(project.tempDir, 'src', 'lib.rs'), 'utf8');
+ expect(libRs).toBe(rustCode);
+
+ // Verify src directory structure
+ const srcStats = await fs.stat(join(project.tempDir, 'src'));
+ expect(srcStats.isDirectory()).toBe(true);
+
+ await project.cleanup();
+ });
+
+ it('should validate input parameters', async () => {
+ const project = await setupProject();
+ testDirs.push(project.tempDir);
+
+ await expect(createRustProject('', 'code')).rejects.toThrow('Invalid tempDir provided');
+ await expect(createRustProject(project.tempDir, '')).rejects.toThrow(
+ 'Invalid rustCode provided'
+ );
+ await expect(createRustProject(project.tempDir, null as unknown as string)).rejects.toThrow(
+ 'Invalid rustCode provided'
+ );
+
+ await project.cleanup();
+ });
+ });
+
+ describe('Integration tests', () => {
+ it('should handle complete workflow with malicious inputs', async () => {
+ // Test complete workflow with various malicious inputs
+ const maliciousInputs = [
+ '../../../malicious',
+ 'CON.txt',
+ 'test',
+ '../../../../etc/passwd',
+ 'very'.repeat(10), // Long name (reduced to avoid filesystem limits)
+ ];
+
+ for (const maliciousInput of maliciousInputs) {
+ const project = await setupProject({ baseName: maliciousInput });
+ testDirs.push(project.tempDir);
+
+ // Should create safe directory
+ expect(project.tempDir.startsWith(tmpdir())).toBe(true);
+ expect(project.tempDir).not.toContain('../');
+
+ // Should be able to create Rust project
+ const rustCode = 'pub fn hello() -> &\'static str { "Hello" }';
+ await createRustProject(project.tempDir, rustCode);
+
+ // Files should exist
+ const stats = await fs.stat(join(project.tempDir, 'Cargo.toml'));
+ expect(stats.isFile()).toBe(true);
+
+ // Cleanup should work
+ await project.cleanup();
+
+ // Directory should be gone
+ await expect(fs.stat(project.tempDir)).rejects.toThrow();
+ }
+ });
+ });
+});
diff --git a/apps/backend/src/utils/fileManager.ts b/apps/backend/src/utils/fileManager.ts
new file mode 100644
index 0000000..45d427b
--- /dev/null
+++ b/apps/backend/src/utils/fileManager.ts
@@ -0,0 +1,230 @@
+import { promises as fs } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { randomBytes } from 'node:crypto';
+import sanitizeFilename from 'sanitize-filename';
+
+/**
+ * Configuration interface for project setup
+ */
+export interface ProjectSetup {
+ /** Absolute path to the temporary directory */
+ tempDir: string;
+ /** Function to clean up the temporary directory */
+ cleanup: () => Promise;
+}
+
+/**
+ * Options for project setup
+ */
+export interface ProjectSetupOptions {
+ /** Base name for the project directory (will be sanitized) */
+ baseName?: string;
+ /** Custom temporary directory root (defaults to OS temp dir) */
+ tempRoot?: string;
+}
+
+/**
+ * Sanitizes a directory name to prevent path traversal and ensure cross-platform compatibility
+ *
+ * @param baseName - The base name to sanitize
+ * @returns A sanitized directory name safe for use across platforms
+ *
+ * @example
+ * ```typescript
+ * getSanitizedDirName('../malicious') // returns 'malicious'
+ * getSanitizedDirName('CON') // returns '' (Windows reserved name)
+ * getSanitizedDirName('my-project') // returns 'my-project'
+ * ```
+ */
+export function getSanitizedDirName(baseName: string): string {
+ if (!baseName || typeof baseName !== 'string') {
+ return '';
+ }
+
+ const trimmed = baseName.trim();
+
+ // Handle whitespace-only strings
+ if (!trimmed) {
+ return '';
+ }
+
+ // Sanitize the filename to remove dangerous characters and reserved names
+ let sanitized = sanitizeFilename(trimmed, { replacement: '_' });
+
+ // Additional cleanup for path traversal attempts
+ sanitized = sanitized.replace(/\.\./g, '').replace(/^[._]+/, '');
+
+ // Ensure it's not too long (filesystem limit is usually 255, leave room for timestamp/random)
+ if (sanitized.length > 50) {
+ sanitized = sanitized.substring(0, 50);
+ }
+
+ // Additional safety: ensure it's not empty after sanitization
+ if (!sanitized || sanitized.length === 0) {
+ return 'project';
+ }
+
+ return sanitized;
+}
+
+/**
+ * Creates a unique, sanitized temporary directory for Rust project compilation
+ *
+ * @param options - Configuration options for directory creation
+ * @returns Promise resolving to ProjectSetup with directory path and cleanup function
+ *
+ * @throws {Error} When directory creation fails
+ *
+ * @example
+ * ```typescript
+ * const project = await setupProject({ baseName: 'my-contract' });
+ * try {
+ * // Use project.tempDir for compilation
+ * console.log('Working in:', project.tempDir);
+ * } finally {
+ * await project.cleanup();
+ * }
+ * ```
+ */
+export async function setupProject(options: ProjectSetupOptions = {}): Promise {
+ const { baseName = 'project', tempRoot = tmpdir() } = options;
+
+ // Create a unique identifier to prevent collisions
+ const timestamp = Date.now();
+ const randomId = randomBytes(8).toString('hex');
+
+ // Sanitize the base name
+ const sanitizedBase = getSanitizedDirName(baseName);
+
+ // Create unique directory name - ensure we always have a base name
+ const finalBaseName = sanitizedBase || 'project';
+ const dirName = `${finalBaseName}_${timestamp}_${randomId}`;
+ const tempDir = join(tempRoot, dirName);
+
+ try {
+ // Create the temporary directory
+ await fs.mkdir(tempDir, { recursive: true });
+
+ // Verify the directory was created and is accessible
+ const stats = await fs.stat(tempDir);
+ if (!stats.isDirectory()) {
+ throw new Error(`Created path is not a directory: ${tempDir}`);
+ }
+
+ return {
+ tempDir,
+ cleanup: () => cleanupProject(tempDir),
+ };
+ } catch (error) {
+ throw new Error(
+ `Failed to create temporary directory: ${error instanceof Error ? error.message : 'Unknown error'}`
+ );
+ }
+}
+
+/**
+ * Safely removes a temporary project directory and all its contents
+ *
+ * @param tempDir - Absolute path to the temporary directory to remove
+ * @throws {Error} When cleanup fails or path validation fails
+ *
+ * @example
+ * ```typescript
+ * await cleanupProject('/tmp/project_1234567890_abcdef');
+ * ```
+ */
+export async function cleanupProject(tempDir: string): Promise {
+ if (!tempDir || typeof tempDir !== 'string') {
+ throw new Error('Invalid tempDir provided for cleanup');
+ }
+
+ // Basic safety check: ensure we're only cleaning temp directories
+ const systemTempDir = tmpdir();
+ if (!tempDir.startsWith(systemTempDir)) {
+ throw new Error(`Refusing to clean directory outside temp folder: ${tempDir}`);
+ }
+
+ try {
+ // Check if directory exists before attempting to remove
+ const stats = await fs.stat(tempDir).catch(() => null);
+ if (!stats) {
+ // Directory doesn't exist, nothing to clean
+ return;
+ }
+
+ if (!stats.isDirectory()) {
+ throw new Error(`Path is not a directory: ${tempDir}`);
+ }
+
+ // Remove the directory and all its contents
+ await fs.rm(tempDir, { recursive: true, force: true });
+ } catch (error) {
+ throw new Error(
+ `Failed to cleanup directory ${tempDir}: ${error instanceof Error ? error.message : 'Unknown error'}`
+ );
+ }
+}
+
+/**
+ * Creates a basic Rust project structure with Cargo.toml and lib.rs
+ *
+ * @param tempDir - The temporary directory to create the project in
+ * @param rustCode - The Rust code to write to lib.rs
+ * @throws {Error} When file creation fails
+ */
+export async function createRustProject(tempDir: string, rustCode: string): Promise {
+ if (!tempDir || typeof tempDir !== 'string') {
+ throw new Error('Invalid tempDir provided');
+ }
+
+ if (!rustCode || typeof rustCode !== 'string') {
+ throw new Error('Invalid rustCode provided');
+ }
+
+ try {
+ // Create Cargo.toml for Soroban contract
+ const cargoToml = `[package]
+name = "temp-contract"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+soroban-sdk = "21"
+
+[dev-dependencies]
+soroban-sdk = { version = "21", features = ["testutils"] }
+
+[profile.release]
+opt-level = "z"
+overflow-checks = true
+debug = 0
+strip = "symbols"
+debug-assertions = false
+panic = "abort"
+codegen-units = 1
+lto = true
+
+[profile.release-with-logs]
+inherits = "release"
+debug-assertions = true
+`;
+
+ // Create src directory
+ const srcDir = join(tempDir, 'src');
+ await fs.mkdir(srcDir, { recursive: true });
+
+ // Write Cargo.toml
+ await fs.writeFile(join(tempDir, 'Cargo.toml'), cargoToml, 'utf8');
+
+ // Write lib.rs
+ await fs.writeFile(join(srcDir, 'lib.rs'), rustCode, 'utf8');
+ } catch (error) {
+ throw new Error(
+ `Failed to create Rust project structure: ${error instanceof Error ? error.message : 'Unknown error'}`
+ );
+ }
+}