UNPKG

create-roadkit

Version:

Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export

1,116 lines (963 loc) 36.5 kB
/** * File operations utilities for RoadKit project scaffolding. * * This module provides comprehensive file and directory operations including * safe copying, template processing, string replacement, directory management, * and permission handling. All operations are designed to be atomic and * include proper error handling and rollback capabilities. */ import { readdir, stat, mkdir, access, constants } from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; import type { TemplateContext, FileOperationResult, Logger, } from '../types/config'; /** * File operation options for customizing behavior */ export interface FileOperationOptions { overwrite?: boolean; preservePermissions?: boolean; createDirectories?: boolean; skipIfExists?: boolean; dryRun?: boolean; } /** * Template processing options for customizing replacement behavior */ export interface TemplateProcessingOptions { preserveOriginal?: boolean; customDelimiters?: { start: string; end: string; }; strictMode?: boolean; // Fail if template variables are not found } /** * Directory copy options for recursive operations */ export interface DirectoryCopyOptions extends FileOperationOptions { filter?: (filePath: string, isDirectory: boolean) => boolean; transform?: (content: string, filePath: string) => Promise<string>; maxDepth?: number; } /** * Security configuration for file operations */ interface SecurityConfig { maxPathDepth: number; allowedExtensions: string[]; blockedPatterns: RegExp[]; maxFileSize: number; maxTemplateVariables: number; } /** * Atomic operation tracker for ensuring rollback consistency */ interface AtomicOperation { id: string; type: 'create' | 'copy' | 'modify' | 'delete'; path: string; backup?: string; isCritical: boolean; rollbackFn: () => Promise<void>; } /** * File operations manager that provides safe, atomic file operations * with comprehensive error handling and rollback capabilities. * * Security Features: * - Path traversal prevention with sanitization * - Command injection protection * - Template variable sanitization and ReDoS protection * - Atomic operations with race condition prevention * - Comprehensive rollback with critical operation detection */ export class FileOperations { private logger: Logger; private operations: FileOperationResult[] = []; private rollbackStack: Array<() => Promise<void>> = []; private atomicOperations: Map<string, AtomicOperation> = new Map(); private securityConfig: SecurityConfig; private operationLocks: Map<string, Promise<void>> = new Map(); /** * Initialize file operations manager with a logger and security configuration * @param logger - Logger instance for tracking operations * @param securityConfig - Optional security configuration for enhanced protection */ constructor(logger: Logger, securityConfig?: Partial<SecurityConfig>) { this.logger = logger; this.securityConfig = { maxPathDepth: 10, allowedExtensions: ['.js', '.ts', '.tsx', '.jsx', '.json', '.md', '.css', '.html', '.yml', '.yaml', '.txt', '.gitignore'], blockedPatterns: [/\.\.|[<>:"|\*\?]/g, /\x00/g, /[\/\\]{2,}/g], maxFileSize: 10 * 1024 * 1024, // 10MB maxTemplateVariables: 100, ...securityConfig, }; } /** * Creates a directory recursively with proper error handling and security validation * * This method safely creates directories with path traversal protection, * handling cases where intermediate directories don't exist. It tracks all created * directories for potential rollback operations and prevents race conditions. * * @param dirPath - Path to the directory to create (will be sanitized) * @param options - Options for directory creation * @returns Operation result with success status and details */ public async createDirectory( dirPath: string, options: FileOperationOptions = {} ): Promise<FileOperationResult> { const { overwrite = false, dryRun = false, } = options; try { // Sanitize and validate the directory path const sanitizedPath = this.sanitizePath(dirPath); if (!sanitizedPath) { throw new Error(`Invalid directory path: ${dirPath}`); } this.logger.debug(`Creating directory: ${sanitizedPath}`); // Use atomic check-and-create to prevent race conditions const lockKey = `dir:${sanitizedPath}`; const existingLock = this.operationLocks.get(lockKey); if (existingLock) { await existingLock; } const operation = this.atomicDirectoryCreate(sanitizedPath, options); this.operationLocks.set(lockKey, operation); const result = await operation; this.operationLocks.delete(lockKey); return result; } catch (error) { const errorMsg = `Failed to create directory ${dirPath}: ${error instanceof Error ? error.message : 'Unknown error'}`; this.logger.error(errorMsg, error); const result: FileOperationResult = { success: false, path: dirPath, operation: 'create', error: errorMsg, }; this.operations.push(result); return result; } } /** * Atomic directory creation with race condition protection */ private async atomicDirectoryCreate( sanitizedPath: string, options: FileOperationOptions ): Promise<FileOperationResult> { const { overwrite = false, dryRun = false, } = options; // Atomic check for directory existence let dirExists = false; try { await access(sanitizedPath, constants.F_OK); const dirStat = await stat(sanitizedPath); dirExists = dirStat.isDirectory(); } catch { // Directory doesn't exist, which is expected } if (dirExists && !overwrite) { this.logger.debug(`Directory already exists: ${sanitizedPath}`); return { success: true, path: sanitizedPath, operation: 'create', skipped: true, reason: 'Directory already exists', }; } if (!dryRun) { await mkdir(sanitizedPath, { recursive: true }); // Create atomic operation for proper rollback const operationId = crypto.randomUUID(); const atomicOp: AtomicOperation = { id: operationId, type: 'create', path: sanitizedPath, isCritical: this.isCriticalPath(sanitizedPath), rollbackFn: async () => { try { // Use fs operations instead of shell commands to prevent injection await this.safeDirectoryRemove(sanitizedPath); } catch (error) { this.logger.warn(`Rollback failed for directory: ${sanitizedPath}`, error); } }, }; this.atomicOperations.set(operationId, atomicOp); this.rollbackStack.push(atomicOp.rollbackFn); } const result: FileOperationResult = { success: true, path: sanitizedPath, operation: 'create', }; this.operations.push(result); this.logger.success(`Directory created: ${sanitizedPath}`); return result; } /** * Copies a file from source to destination with template processing * * This method provides safe file copying with optional template processing * for customizing content during the copy operation. It handles binary files, * maintains permissions, and provides rollback capabilities. * * @param sourcePath - Path to the source file * @param destPath - Path to the destination file * @param templateContext - Optional template context for processing * @param options - Options for file copy operation * @returns Operation result with success status and details */ public async copyFile( sourcePath: string, destPath: string, templateContext?: TemplateContext, options: FileOperationOptions = {} ): Promise<FileOperationResult> { const { overwrite = false, preservePermissions = true, createDirectories = true, skipIfExists = false, dryRun = false, } = options; try { this.logger.debug(`Copying file: ${sourcePath} -> ${destPath}`); // Check if source file exists const sourceFile = Bun.file(sourcePath); if (!await sourceFile.exists()) { throw new Error(`Source file not found: ${sourcePath}`); } // Check if destination exists const destFile = Bun.file(destPath); const destExists = await destFile.exists(); if (destExists && skipIfExists) { this.logger.debug(`File already exists, skipping: ${destPath}`); return { success: true, path: destPath, operation: 'copy', skipped: true, reason: 'File already exists and skipIfExists is true', }; } if (destExists && !overwrite) { throw new Error(`Destination file exists and overwrite is false: ${destPath}`); } // Create destination directory if needed if (createDirectories) { const destDir = path.dirname(destPath); await this.createDirectory(destDir, { overwrite: true }); } if (!dryRun) { // Read source content let content: string | ArrayBuffer; const isTextFile = this.isTextFile(sourcePath); if (isTextFile) { content = await sourceFile.text(); // Process template if context is provided if (templateContext) { content = this.processTemplate(content, templateContext); } // Write text content await Bun.write(destPath, content); } else { // Copy binary file directly content = await sourceFile.arrayBuffer(); await Bun.write(destPath, content); } // Preserve permissions if requested if (preservePermissions) { try { const sourceStats = await stat(sourcePath); await Bun.$`chmod ${sourceStats.mode.toString(8).slice(-3)} ${destPath}`.quiet(); } catch (error) { this.logger.warn(`Failed to preserve permissions for ${destPath}`, error); } } // Add rollback operation this.rollbackStack.push(async () => { try { await Bun.unlink(destPath); } catch { // Ignore rollback errors } }); } const result: FileOperationResult = { success: true, path: destPath, operation: 'copy', }; this.operations.push(result); this.logger.success(`File copied: ${sourcePath} -> ${destPath}`); return result; } catch (error) { const errorMsg = `Failed to copy file ${sourcePath} -> ${destPath}: ${error instanceof Error ? error.message : 'Unknown error'}`; this.logger.error(errorMsg, error); const result: FileOperationResult = { success: false, path: destPath, operation: 'copy', error: errorMsg, }; this.operations.push(result); return result; } } /** * Copies a directory recursively with template processing and filtering * * This method provides comprehensive directory copying with support for * filtering files, transforming content, and processing templates. It * maintains the directory structure and handles both text and binary files. * * @param sourcePath - Path to the source directory * @param destPath - Path to the destination directory * @param templateContext - Optional template context for processing * @param options - Options for directory copy operation * @returns Array of operation results for all files processed */ public async copyDirectory( sourcePath: string, destPath: string, templateContext?: TemplateContext, options: DirectoryCopyOptions = {} ): Promise<FileOperationResult[]> { const { filter = () => true, transform, maxDepth = 50, ...fileOptions } = options; const results: FileOperationResult[] = []; try { this.logger.debug(`Copying directory: ${sourcePath} -> ${destPath}`); // Validate source directory try { const sourceStats = await stat(sourcePath); if (!sourceStats.isDirectory()) { throw new Error(`Source is not a directory: ${sourcePath}`); } } catch (error) { throw new Error(`Source directory not found: ${sourcePath}`); } // Create destination directory const createResult = await this.createDirectory(destPath, fileOptions); results.push(createResult); if (!createResult.success) { return results; } // Process directory recursively await this.copyDirectoryRecursive( sourcePath, destPath, templateContext, options, results, 0, maxDepth ); this.logger.success(`Directory copied: ${sourcePath} -> ${destPath} (${results.length} items)`); return results; } catch (error) { const errorMsg = `Failed to copy directory ${sourcePath} -> ${destPath}: ${error instanceof Error ? error.message : 'Unknown error'}`; this.logger.error(errorMsg, error); results.push({ success: false, path: destPath, operation: 'copy', error: errorMsg, }); return results; } } /** * Recursive helper method for directory copying */ private async copyDirectoryRecursive( sourcePath: string, destPath: string, templateContext: TemplateContext | undefined, options: DirectoryCopyOptions, results: FileOperationResult[], depth: number, maxDepth: number ): Promise<void> { if (depth >= maxDepth) { this.logger.warn(`Maximum directory depth reached: ${maxDepth}`); return; } const { filter = () => true, transform } = options; try { const entries = await readdir(sourcePath, { withFileTypes: true }); for (const entry of entries) { // SECURITY FIX: Sanitize entry.name to prevent path traversal attacks const sanitizedName = this.sanitizeFileName(entry.name); if (!sanitizedName) { this.logger.warn(`Skipping invalid filename: ${entry.name}`); continue; } const sourceItemPath = path.join(sourcePath, sanitizedName); const destItemPath = path.join(destPath, sanitizedName); // Apply filter if (!filter(sourceItemPath, entry.isDirectory())) { this.logger.debug(`Filtered out: ${sourceItemPath}`); continue; } if (entry.isDirectory()) { // Create subdirectory and recurse const createResult = await this.createDirectory(destItemPath, options); results.push(createResult); if (createResult.success) { await this.copyDirectoryRecursive( sourceItemPath, destItemPath, templateContext, options, results, depth + 1, maxDepth ); } } else if (entry.isFile()) { // Copy file with optional transformation let fileTemplateContext = templateContext; // Apply custom transformation if provided if (transform && this.isTextFile(sourceItemPath)) { try { const originalContent = await Bun.file(sourceItemPath).text(); const transformedContent = await transform(originalContent, sourceItemPath); // Create a temporary context with transformed content // This is a workaround to apply custom transformations const tempFile = path.join(await this.getTempDir(), `temp_${Date.now()}_${entry.name}`); await Bun.write(tempFile, transformedContent); const copyResult = await this.copyFile(tempFile, destItemPath, fileTemplateContext, options); results.push(copyResult); // Clean up temporary file try { await Bun.unlink(tempFile); } catch { // Ignore cleanup errors } continue; } catch (error) { this.logger.warn(`Failed to apply transformation to ${sourceItemPath}`, error); // Fall back to normal copy } } // Normal file copy const copyResult = await this.copyFile(sourceItemPath, destItemPath, fileTemplateContext, options); results.push(copyResult); } } } catch (error) { this.logger.error(`Failed to read directory: ${sourcePath}`, error); results.push({ success: false, path: sourcePath, operation: 'copy', error: `Failed to read directory: ${error instanceof Error ? error.message : 'Unknown error'}`, }); } } /** * Processes template content by replacing placeholders with context values * * This method handles template string replacement with comprehensive security * measures including input sanitization, ReDoS attack protection, and * template variable limits to prevent injection attacks. * * @param content - Template content to process (will be sanitized) * @param context - Template context with replacement values (will be sanitized) * @param options - Processing options * @returns Processed content with placeholders replaced securely */ public processTemplate( content: string, context: TemplateContext, options: TemplateProcessingOptions = {} ): string { const { customDelimiters = { start: '{{', end: '}}' }, strictMode = false, } = options; try { // SECURITY: Sanitize content to prevent injection attacks const sanitizedContent = this.sanitizeTemplateContent(content); const sanitizedContext = this.sanitizeTemplateContext(context); let processedContent = sanitizedContent; const { start, end } = customDelimiters; // SECURITY: Create regex pattern with ReDoS protection const safeStart = this.escapeRegex(start); const safeEnd = this.escapeRegex(end); // More permissive regex to catch malicious templates, then validate the content const templateRegex = new RegExp(`${safeStart}\\s*([^}]+)\\s*${safeEnd}`, 'g'); const missingVariables: string[] = []; let replacementCount = 0; // SECURITY: Limit template variable replacements to prevent DoS const maxReplacements = this.securityConfig.maxTemplateVariables; processedContent = processedContent.replace(templateRegex, (match, variableName) => { if (++replacementCount > maxReplacements) { this.logger.warn(`Template variable limit exceeded: ${maxReplacements}, blocking further replacements`); return '[BLOCKED: Too many variables]'; } // SECURITY: Block dangerous variable names const dangerousPatterns = [ 'constructor', 'prototype', 'process', 'require', 'eval', 'Function', 'global', 'window', 'document', '__proto__', 'import', 'export' ]; if (dangerousPatterns.some(pattern => variableName.toLowerCase().includes(pattern.toLowerCase()))) { this.logger.warn(`Blocked dangerous template variable: ${variableName}`); return '[BLOCKED: Dangerous variable]'; } const value = this.getNestedValue(sanitizedContext, variableName); if (value === undefined || value === null) { if (strictMode) { missingVariables.push(variableName); return match; // Keep original placeholder } else { this.logger.warn(`Template variable not found: ${variableName}, using empty string`); return ''; } } // SECURITY: Sanitize the replacement value return this.sanitizeTemplateValue(String(value)); }); if (strictMode && missingVariables.length > 0) { throw new Error(`Missing template variables: ${missingVariables.join(', ')}`); } return processedContent; } catch (error) { this.logger.error('Failed to process template content', error); throw error; } } /** * Creates a new file with content and optional template processing * * @param filePath - Path to the file to create * @param content - Content to write to the file * @param templateContext - Optional template context for processing * @param options - File creation options * @returns Operation result with success status and details */ public async createFile( filePath: string, content: string, templateContext?: TemplateContext, options: FileOperationOptions = {} ): Promise<FileOperationResult> { const { overwrite = false, createDirectories = true, dryRun = false, } = options; try { // SECURITY: Sanitize and validate the file path const sanitizedPath = this.sanitizePath(filePath); if (!sanitizedPath) { throw new Error(`Invalid file path: ${filePath}`); } // SECURITY: Validate the filename const fileName = path.basename(sanitizedPath); const sanitizedFileName = this.sanitizeFileName(fileName); if (!sanitizedFileName) { throw new Error(`Invalid filename: ${fileName}`); } // Reconstruct path with sanitized filename const finalPath = path.join(path.dirname(sanitizedPath), sanitizedFileName); this.logger.debug(`Creating file: ${finalPath}`); // Check if file already exists const fileExists = await Bun.file(finalPath).exists(); if (fileExists && !overwrite) { throw new Error(`File already exists and overwrite is false: ${finalPath}`); } // Create directory if needed if (createDirectories) { const fileDir = path.dirname(finalPath); await this.createDirectory(fileDir, { overwrite: true }); } // Process template content if context is provided let processedContent = content; if (templateContext) { processedContent = this.processTemplate(content, templateContext); } if (!dryRun) { await Bun.write(finalPath, processedContent); // Create atomic operation for proper rollback const operationId = crypto.randomUUID(); const atomicOp: AtomicOperation = { id: operationId, type: 'create', path: finalPath, isCritical: this.isCriticalPath(finalPath), rollbackFn: async () => { try { await Bun.unlink(finalPath); } catch (error) { this.logger.warn(`Rollback failed for file: ${finalPath}`, error); } }, }; this.atomicOperations.set(operationId, atomicOp); this.rollbackStack.push(atomicOp.rollbackFn); } const result: FileOperationResult = { success: true, path: finalPath, operation: 'create', }; this.operations.push(result); this.logger.success(`File created: ${finalPath}`); return result; } catch (error) { const errorMsg = `Failed to create file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`; this.logger.error(errorMsg, error); const result: FileOperationResult = { success: false, path: filePath, operation: 'create', error: errorMsg, }; this.operations.push(result); return result; } } /** * Rolls back all file operations performed by this instance * * This method attempts to undo all file operations in reverse order, * providing a way to clean up partially completed operations. * * @returns Array of rollback results */ public async rollback(): Promise<Array<{ success: boolean; error?: string }>> { this.logger.info(`Starting rollback of ${this.rollbackStack.length} operations`); const rollbackResults: Array<{ success: boolean; error?: string }> = []; // Execute rollback operations in reverse order for (const rollbackOp of this.rollbackStack.reverse()) { try { await rollbackOp(); rollbackResults.push({ success: true }); } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; rollbackResults.push({ success: false, error: errorMsg }); this.logger.warn(`Rollback operation failed: ${errorMsg}`); } } // Clear the rollback stack this.rollbackStack.length = 0; this.operations.length = 0; const successCount = rollbackResults.filter(r => r.success).length; this.logger.info(`Rollback completed: ${successCount}/${rollbackResults.length} operations succeeded`); return rollbackResults; } /** * Gets all file operations performed by this instance * @returns Array of file operation results */ public getOperations(): FileOperationResult[] { return [...this.operations]; } /** * Clears the operation history and rollback stack */ public clear(): void { this.operations.length = 0; this.rollbackStack.length = 0; this.atomicOperations.clear(); this.operationLocks.clear(); } // Utility methods /** * Determines if a file is a text file based on its extension * @param filePath - Path to the file to check * @returns True if the file is likely a text file */ private isTextFile(filePath: string): boolean { const textExtensions = [ '.txt', '.md', '.json', '.js', '.ts', '.tsx', '.jsx', '.css', '.scss', '.sass', '.less', '.html', '.htm', '.xml', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf', '.env', '.gitignore', '.gitattributes', '.editorconfig', '.prettierrc', '.eslintrc', ]; const ext = path.extname(filePath).toLowerCase(); return textExtensions.includes(ext) || !ext; // Files without extension are assumed to be text } /** * Escapes special regex characters in a string * @param str - String to escape * @returns Escaped string safe for use in regex */ private escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Gets a nested value from an object using dot notation * @param obj - Object to search in * @param path - Dot-separated path to the value * @returns Value at the path or undefined if not found */ private getNestedValue(obj: any, path: string): any { return path.split('.').reduce((current, key) => { return current && current[key] !== undefined ? current[key] : undefined; }, obj); } /** * Gets a temporary directory path for intermediate operations * @returns Path to temporary directory */ private async getTempDir(): Promise<string> { const tempDir = path.join(process.cwd(), '.roadkit-temp'); await this.createDirectory(tempDir, { overwrite: true }); return tempDir; } // ============================================================================ // SECURITY UTILITY METHODS // ============================================================================ /** * Sanitizes a file path to prevent path traversal attacks */ private sanitizePath(filePath: string): string | null { try { if (!filePath || typeof filePath !== 'string') { return null; } let cleaned = filePath.replace(/\x00/g, ''); // If null bytes were found, reject the path entirely if (cleaned !== filePath) { this.logger.warn(`Null byte injection attempt detected: ${filePath}`); return null; } for (const pattern of this.securityConfig.blockedPatterns) { if (pattern.test(cleaned)) { this.logger.warn(`Blocked dangerous path pattern: ${filePath}`); return null; } } cleaned = path.normalize(cleaned); if (cleaned.includes('..')) { this.logger.warn(`Path traversal attempt detected: ${filePath}`); return null; } const pathParts = cleaned.split(path.sep).filter(part => part.length > 0); if (pathParts.length > this.securityConfig.maxPathDepth) { this.logger.warn(`Path depth exceeded: ${filePath}`); return null; } return cleaned; } catch (error) { this.logger.error('Path sanitization failed', error); return null; } } /** * Sanitizes a filename to prevent path traversal and injection attacks */ private sanitizeFileName(fileName: string): string | null { try { if (!fileName || typeof fileName !== 'string') { return null; } // Remove dangerous characters including HTML special chars, quotes, pipes, etc. let cleaned = fileName.replace(/[\x00-\x1f\x7f<>:"|\\*\\?]/g, ''); // Remove path traversal components if (cleaned.includes('..') || cleaned.includes('/') || cleaned.includes('\\')) { this.logger.warn(`Invalid filename detected: ${fileName}`); return null; } // Check reserved names (Windows) const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']; const nameWithoutExt = path.parse(cleaned).name.toUpperCase(); if (reservedNames.includes(nameWithoutExt)) { this.logger.warn(`Reserved filename detected: ${fileName}`); return null; } if (cleaned.length === 0 || cleaned.length > 255) { return null; } // Check file extension const ext = path.extname(cleaned).toLowerCase(); if (ext && !this.securityConfig.allowedExtensions.includes(ext)) { this.logger.warn(`Blocked file extension: ${ext} in ${fileName}`); return null; } return cleaned; } catch (error) { this.logger.error('Filename sanitization failed', error); return null; } } /** * Sanitizes template content to prevent injection attacks */ private sanitizeTemplateContent(content: string): string { if (!content || typeof content !== 'string') { return ''; } let sanitized = content.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); const maxLength = 1024 * 1024; // 1MB if (sanitized.length > maxLength) { this.logger.warn('Template content truncated due to size limit'); sanitized = sanitized.substring(0, maxLength); } return sanitized; } /** * Sanitizes template context to prevent injection attacks */ private sanitizeTemplateContext(context: TemplateContext): TemplateContext { const sanitized: TemplateContext = {}; const sanitizeValue = (value: any): any => { if (typeof value === 'string') { return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); } else if (typeof value === 'object' && value !== null) { if (Array.isArray(value)) { return value.map(sanitizeValue); } else { const sanitizedObj: any = {}; for (const [key, val] of Object.entries(value)) { const sanitizedKey = key.replace(/[^a-zA-Z0-9_]/g, ''); if (sanitizedKey) { sanitizedObj[sanitizedKey] = sanitizeValue(val); } } return sanitizedObj; } } return value; }; for (const [key, value] of Object.entries(context)) { const sanitizedKey = key.replace(/[^a-zA-Z0-9_]/g, ''); if (sanitizedKey) { sanitized[sanitizedKey] = sanitizeValue(value); } } return sanitized; } /** * Sanitizes a template replacement value */ private sanitizeTemplateValue(value: string): string { return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); } /** * Safely removes a directory without using shell commands */ private async safeDirectoryRemove(dirPath: string): Promise<void> { try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { await this.safeDirectoryRemove(fullPath); } else { await Bun.unlink(fullPath); } } await Bun.rmdir(dirPath); } catch (error) { this.logger.debug(`Safe directory remove failed (acceptable): ${dirPath}`, error); } } /** * Determines if a path is critical for system operation */ private isCriticalPath(filePath: string): boolean { const criticalPaths = ['package.json', 'tsconfig.json', 'next.config.js', '.gitignore']; const fileName = path.basename(filePath); return criticalPaths.includes(fileName); } /** * Enhanced rollback with atomic operation support */ public async enhancedRollback(): Promise<{ success: boolean; criticalFailures: string[]; warnings: string[]; operationsRolledBack: number; operationsFailed: number; }> { this.logger.info(`Starting enhanced rollback of ${this.atomicOperations.size} atomic operations`); const criticalFailures: string[] = []; const warnings: string[] = []; let operationsRolledBack = 0; let operationsFailed = 0; const operations = Array.from(this.atomicOperations.values()).sort((a, b) => { if (a.isCritical === b.isCritical) return 0; return a.isCritical ? 1 : -1; }); for (const operation of operations.reverse()) { try { await operation.rollbackFn(); operationsRolledBack++; this.logger.debug(`Rolled back operation: ${operation.id} (${operation.path})`); } catch (error) { operationsFailed++; const errorMsg = error instanceof Error ? error.message : 'Unknown error'; if (operation.isCritical) { criticalFailures.push(`Critical operation rollback failed: ${operation.path} - ${errorMsg}`); this.logger.error(`Critical rollback failure: ${operation.path}`, error); } else { warnings.push(`Non-critical operation rollback failed: ${operation.path} - ${errorMsg}`); this.logger.warn(`Non-critical rollback failure: ${operation.path}`, error); } } } this.atomicOperations.clear(); this.rollbackStack.length = 0; this.operations.length = 0; this.operationLocks.clear(); const success = criticalFailures.length === 0; this.logger.info(`Enhanced rollback completed: ${operationsRolledBack} succeeded, ${operationsFailed} failed`); return { success, criticalFailures, warnings, operationsRolledBack, operationsFailed, }; } } /** * Factory function to create a FileOperations instance with security configuration * @param logger - Logger instance for tracking operations * @param securityConfig - Optional security configuration for enhanced protection * @returns Configured FileOperations instance with security enhancements */ export const createFileOperations = ( logger: Logger, securityConfig?: Partial<SecurityConfig> ): FileOperations => { return new FileOperations(logger, securityConfig); }; /** * Factory function to create a FileOperations instance with high-security configuration * @param logger - Logger instance for tracking operations * @returns FileOperations instance configured for maximum security */ export const createSecureFileOperations = (logger: Logger): FileOperations => { const highSecurityConfig: Partial<SecurityConfig> = { maxPathDepth: 5, allowedExtensions: ['.js', '.ts', '.tsx', '.jsx', '.json', '.md', '.css', '.html', '.yml', '.yaml', '.txt'], blockedPatterns: [/\.\.|[<>:"|\\*\\?]/g, /\x00/g, /[\/\\]{2,}/g, /[\x00-\x1f\x7f]/g], maxFileSize: 5 * 1024 * 1024, // 5MB maxTemplateVariables: 50, }; return new FileOperations(logger, highSecurityConfig); };