| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import fs from 'fs/promises'; |
| import fsSync, { type Dirent, type Stats } from 'fs'; |
| import path from 'path'; |
| import pLimit from 'p-limit'; |
| import { validatePath } from './security.js'; |
|
|
| |
| |
| |
| interface ThrottleConfig { |
| |
| maxConcurrency: number; |
| |
| maxRetries: number; |
| |
| baseDelay: number; |
| |
| maxDelay: number; |
| } |
|
|
| const DEFAULT_CONFIG: ThrottleConfig = { |
| maxConcurrency: 100, |
| maxRetries: 3, |
| baseDelay: 100, |
| maxDelay: 5000, |
| }; |
|
|
| let config: ThrottleConfig = { ...DEFAULT_CONFIG }; |
| let fsLimit = pLimit(config.maxConcurrency); |
|
|
| |
| |
| |
| |
| export function configureThrottling(newConfig: Partial<ThrottleConfig>): void { |
| const newConcurrency = newConfig.maxConcurrency; |
|
|
| if (newConcurrency !== undefined && newConcurrency !== config.maxConcurrency) { |
| if (fsLimit.activeCount > 0 || fsLimit.pendingCount > 0) { |
| throw new Error( |
| `[SecureFS] Cannot change maxConcurrency while operations are in flight. Active: ${fsLimit.activeCount}, Pending: ${fsLimit.pendingCount}` |
| ); |
| } |
| fsLimit = pLimit(newConcurrency); |
| } |
|
|
| config = { ...config, ...newConfig }; |
| } |
|
|
| |
| |
| |
| export function getThrottlingConfig(): Readonly<ThrottleConfig> { |
| return { ...config }; |
| } |
|
|
| |
| |
| |
| export function getPendingOperations(): number { |
| return fsLimit.pendingCount; |
| } |
|
|
| |
| |
| |
| export function getActiveOperations(): number { |
| return fsLimit.activeCount; |
| } |
|
|
| |
| |
| |
| const FILE_DESCRIPTOR_ERROR_CODES = new Set(['ENFILE', 'EMFILE']); |
|
|
| |
| |
| |
| function isFileDescriptorError(error: unknown): boolean { |
| if (error && typeof error === 'object' && 'code' in error) { |
| return FILE_DESCRIPTOR_ERROR_CODES.has((error as { code: string }).code); |
| } |
| return false; |
| } |
|
|
| |
| |
| |
| function calculateDelay(attempt: number): number { |
| const exponentialDelay = config.baseDelay * Math.pow(2, attempt); |
| const jitter = Math.random() * config.baseDelay; |
| return Math.min(exponentialDelay + jitter, config.maxDelay); |
| } |
|
|
| |
| |
| |
| function sleep(ms: number): Promise<void> { |
| return new Promise((resolve) => setTimeout(resolve, ms)); |
| } |
|
|
| |
| |
| |
| async function executeWithRetry<T>(operation: () => Promise<T>, operationName: string): Promise<T> { |
| return fsLimit(async () => { |
| let lastError: unknown; |
|
|
| for (let attempt = 0; attempt <= config.maxRetries; attempt++) { |
| try { |
| return await operation(); |
| } catch (error) { |
| lastError = error; |
|
|
| if (isFileDescriptorError(error) && attempt < config.maxRetries) { |
| const delay = calculateDelay(attempt); |
| console.warn( |
| `[SecureFS] ${operationName}: File descriptor error (attempt ${attempt + 1}/${config.maxRetries + 1}), retrying in ${delay}ms` |
| ); |
| await sleep(delay); |
| continue; |
| } |
|
|
| throw error; |
| } |
| } |
|
|
| throw lastError; |
| }); |
| } |
|
|
| |
| |
| |
| export async function access(filePath: string, mode?: number): Promise<void> { |
| const validatedPath = validatePath(filePath); |
| return executeWithRetry(() => fs.access(validatedPath, mode), `access(${filePath})`); |
| } |
|
|
| |
| |
| |
| export async function readFile( |
| filePath: string, |
| encoding?: BufferEncoding |
| ): Promise<string | Buffer> { |
| const validatedPath = validatePath(filePath); |
| return executeWithRetry<string | Buffer>(() => { |
| if (encoding) { |
| return fs.readFile(validatedPath, encoding); |
| } |
| return fs.readFile(validatedPath); |
| }, `readFile(${filePath})`); |
| } |
|
|
| |
| |
| |
| export interface WriteFileOptions { |
| encoding?: BufferEncoding; |
| mode?: number; |
| flag?: string; |
| } |
|
|
| |
| |
| |
| export async function writeFile( |
| filePath: string, |
| data: string | Buffer, |
| optionsOrEncoding?: BufferEncoding | WriteFileOptions |
| ): Promise<void> { |
| const validatedPath = validatePath(filePath); |
| return executeWithRetry( |
| () => fs.writeFile(validatedPath, data, optionsOrEncoding), |
| `writeFile(${filePath})` |
| ); |
| } |
|
|
| |
| |
| |
| export async function mkdir( |
| dirPath: string, |
| options?: { recursive?: boolean; mode?: number } |
| ): Promise<string | undefined> { |
| const validatedPath = validatePath(dirPath); |
| return executeWithRetry(() => fs.mkdir(validatedPath, options), `mkdir(${dirPath})`); |
| } |
|
|
| |
| |
| |
| export async function readdir( |
| dirPath: string, |
| options?: { withFileTypes?: false; encoding?: BufferEncoding } |
| ): Promise<string[]>; |
| export async function readdir( |
| dirPath: string, |
| options: { withFileTypes: true; encoding?: BufferEncoding } |
| ): Promise<Dirent[]>; |
| export async function readdir( |
| dirPath: string, |
| options?: { withFileTypes?: boolean; encoding?: BufferEncoding } |
| ): Promise<string[] | Dirent[]> { |
| const validatedPath = validatePath(dirPath); |
| return executeWithRetry<string[] | Dirent[]>(() => { |
| if (options?.withFileTypes === true) { |
| return fs.readdir(validatedPath, { withFileTypes: true }); |
| } |
| return fs.readdir(validatedPath); |
| }, `readdir(${dirPath})`); |
| } |
|
|
| |
| |
| |
| export async function stat(filePath: string): Promise<ReturnType<typeof fs.stat>> { |
| const validatedPath = validatePath(filePath); |
| return executeWithRetry(() => fs.stat(validatedPath), `stat(${filePath})`); |
| } |
|
|
| |
| |
| |
| export async function rm( |
| filePath: string, |
| options?: { recursive?: boolean; force?: boolean } |
| ): Promise<void> { |
| const validatedPath = validatePath(filePath); |
| return executeWithRetry(() => fs.rm(validatedPath, options), `rm(${filePath})`); |
| } |
|
|
| |
| |
| |
| export async function unlink(filePath: string): Promise<void> { |
| const validatedPath = validatePath(filePath); |
| return executeWithRetry(() => fs.unlink(validatedPath), `unlink(${filePath})`); |
| } |
|
|
| |
| |
| |
| export async function copyFile(src: string, dest: string, mode?: number): Promise<void> { |
| const validatedSrc = validatePath(src); |
| const validatedDest = validatePath(dest); |
| return executeWithRetry( |
| () => fs.copyFile(validatedSrc, validatedDest, mode), |
| `copyFile(${src}, ${dest})` |
| ); |
| } |
|
|
| |
| |
| |
| export async function appendFile( |
| filePath: string, |
| data: string | Buffer, |
| encoding?: BufferEncoding |
| ): Promise<void> { |
| const validatedPath = validatePath(filePath); |
| return executeWithRetry( |
| () => fs.appendFile(validatedPath, data, encoding), |
| `appendFile(${filePath})` |
| ); |
| } |
|
|
| |
| |
| |
| export async function rename(oldPath: string, newPath: string): Promise<void> { |
| const validatedOldPath = validatePath(oldPath); |
| const validatedNewPath = validatePath(newPath); |
| return executeWithRetry( |
| () => fs.rename(validatedOldPath, validatedNewPath), |
| `rename(${oldPath}, ${newPath})` |
| ); |
| } |
|
|
| |
| |
| |
| |
| export async function lstat(filePath: string): Promise<ReturnType<typeof fs.lstat>> { |
| const validatedPath = validatePath(filePath); |
| return executeWithRetry(() => fs.lstat(validatedPath), `lstat(${filePath})`); |
| } |
|
|
| |
| |
| |
| |
| export function joinPath(...pathSegments: string[]): string { |
| return path.join(...pathSegments); |
| } |
|
|
| |
| |
| |
| |
| export function resolvePath(...pathSegments: string[]): string { |
| return path.resolve(...pathSegments); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export interface WriteFileSyncOptions { |
| encoding?: BufferEncoding; |
| mode?: number; |
| flag?: string; |
| } |
|
|
| |
| |
| |
| export function existsSync(filePath: string): boolean { |
| const validatedPath = validatePath(filePath); |
| return fsSync.existsSync(validatedPath); |
| } |
|
|
| |
| |
| |
| export function readFileSync(filePath: string, encoding?: BufferEncoding): string | Buffer { |
| const validatedPath = validatePath(filePath); |
| if (encoding) { |
| return fsSync.readFileSync(validatedPath, encoding); |
| } |
| return fsSync.readFileSync(validatedPath); |
| } |
|
|
| |
| |
| |
| export function writeFileSync( |
| filePath: string, |
| data: string | Buffer, |
| options?: WriteFileSyncOptions |
| ): void { |
| const validatedPath = validatePath(filePath); |
| fsSync.writeFileSync(validatedPath, data, options); |
| } |
|
|
| |
| |
| |
| export function mkdirSync( |
| dirPath: string, |
| options?: { recursive?: boolean; mode?: number } |
| ): string | undefined { |
| const validatedPath = validatePath(dirPath); |
| return fsSync.mkdirSync(validatedPath, options); |
| } |
|
|
| |
| |
| |
| export function readdirSync(dirPath: string, options?: { withFileTypes?: false }): string[]; |
| export function readdirSync(dirPath: string, options: { withFileTypes: true }): Dirent[]; |
| export function readdirSync( |
| dirPath: string, |
| options?: { withFileTypes?: boolean } |
| ): string[] | Dirent[] { |
| const validatedPath = validatePath(dirPath); |
| if (options?.withFileTypes === true) { |
| return fsSync.readdirSync(validatedPath, { withFileTypes: true }); |
| } |
| return fsSync.readdirSync(validatedPath); |
| } |
|
|
| |
| |
| |
| export function statSync(filePath: string): Stats { |
| const validatedPath = validatePath(filePath); |
| return fsSync.statSync(validatedPath); |
| } |
|
|
| |
| |
| |
| export function accessSync(filePath: string, mode?: number): void { |
| const validatedPath = validatePath(filePath); |
| fsSync.accessSync(validatedPath, mode); |
| } |
|
|
| |
| |
| |
| export function unlinkSync(filePath: string): void { |
| const validatedPath = validatePath(filePath); |
| fsSync.unlinkSync(validatedPath); |
| } |
|
|
| |
| |
| |
| export function rmSync(filePath: string, options?: { recursive?: boolean; force?: boolean }): void { |
| const validatedPath = validatePath(filePath); |
| fsSync.rmSync(validatedPath, options); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| export async function readEnvFile(envPath: string): Promise<Record<string, string>> { |
| const validatedPath = validatePath(envPath); |
| try { |
| const content = await executeWithRetry( |
| () => fs.readFile(validatedPath, 'utf-8'), |
| `readEnvFile(${envPath})` |
| ); |
| return parseEnvContent(content); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
| return {}; |
| } |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| export function readEnvFileSync(envPath: string): Record<string, string> { |
| const validatedPath = validatePath(envPath); |
| try { |
| const content = fsSync.readFileSync(validatedPath, 'utf-8'); |
| return parseEnvContent(content); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
| return {}; |
| } |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| function parseEnvContent(content: string): Record<string, string> { |
| const result: Record<string, string> = {}; |
| const lines = content.split('\n'); |
|
|
| for (const line of lines) { |
| const trimmed = line.trim(); |
| |
| if (!trimmed || trimmed.startsWith('#')) { |
| continue; |
| } |
| const equalIndex = trimmed.indexOf('='); |
| if (equalIndex > 0) { |
| const key = trimmed.slice(0, equalIndex).trim(); |
| const value = trimmed.slice(equalIndex + 1).trim(); |
| result[key] = value; |
| } |
| } |
|
|
| return result; |
| } |
|
|
| |
| |
| |
| |
| export async function writeEnvKey(envPath: string, key: string, value: string): Promise<void> { |
| const validatedPath = validatePath(envPath); |
|
|
| let content = ''; |
| try { |
| content = await executeWithRetry( |
| () => fs.readFile(validatedPath, 'utf-8'), |
| `readFile(${envPath})` |
| ); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { |
| throw error; |
| } |
| |
| } |
|
|
| const newContent = updateEnvContent(content, key, value); |
| await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`); |
| } |
|
|
| |
| |
| |
| export function writeEnvKeySync(envPath: string, key: string, value: string): void { |
| const validatedPath = validatePath(envPath); |
|
|
| let content = ''; |
| try { |
| content = fsSync.readFileSync(validatedPath, 'utf-8'); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { |
| throw error; |
| } |
| |
| } |
|
|
| const newContent = updateEnvContent(content, key, value); |
| fsSync.writeFileSync(validatedPath, newContent); |
| } |
|
|
| |
| |
| |
| export async function removeEnvKey(envPath: string, key: string): Promise<void> { |
| const validatedPath = validatePath(envPath); |
|
|
| let content = ''; |
| try { |
| content = await executeWithRetry( |
| () => fs.readFile(validatedPath, 'utf-8'), |
| `readFile(${envPath})` |
| ); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
| return; |
| } |
| throw error; |
| } |
|
|
| const newContent = removeEnvKeyFromContent(content, key); |
| await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`); |
| } |
|
|
| |
| |
| |
| export function removeEnvKeySync(envPath: string, key: string): void { |
| const validatedPath = validatePath(envPath); |
|
|
| let content = ''; |
| try { |
| content = fsSync.readFileSync(validatedPath, 'utf-8'); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
| return; |
| } |
| throw error; |
| } |
|
|
| const newContent = removeEnvKeyFromContent(content, key); |
| fsSync.writeFileSync(validatedPath, newContent); |
| } |
|
|
| |
| |
| |
| function updateEnvContent(content: string, key: string, value: string): string { |
| const lines = content.split('\n'); |
| const keyPrefix = `${key}=`; |
| let found = false; |
|
|
| const newLines = lines.map((line) => { |
| if (line.trim().startsWith(keyPrefix)) { |
| found = true; |
| return `${key}=${value}`; |
| } |
| return line; |
| }); |
|
|
| if (!found) { |
| |
| if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') { |
| newLines.push(`${key}=${value}`); |
| } else { |
| |
| if (newLines.length === 0 || (newLines.length === 1 && newLines[0] === '')) { |
| newLines[0] = `${key}=${value}`; |
| } else { |
| newLines[newLines.length - 1] = `${key}=${value}`; |
| } |
| } |
| } |
|
|
| |
| let result = newLines.join('\n'); |
| if (!result.endsWith('\n')) { |
| result += '\n'; |
| } |
| return result; |
| } |
|
|
| |
| |
| |
| function removeEnvKeyFromContent(content: string, key: string): string { |
| const lines = content.split('\n'); |
| const keyPrefix = `${key}=`; |
| const newLines = lines.filter((line) => !line.trim().startsWith(keyPrefix)); |
|
|
| |
| while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') { |
| newLines.pop(); |
| } |
|
|
| |
| let result = newLines.join('\n'); |
| if (result.length > 0 && !result.endsWith('\n')) { |
| result += '\n'; |
| } |
| return result; |
| } |
|
|