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
text/typescript
/**
* 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);
};