lib.ts•7.7 kB
import fs from "fs/promises";
import path from "path";
import { normalizePath, expandHome } from './path-utils.js';
import { isPathWithinAllowedDirectories } from './path-validation.js';
// Global allowed directories - set by the main module
let allowedDirectories: string[] = [];
// Function to set allowed directories from the main module
export function setAllowedDirectories(directories: string[]): void {
allowedDirectories = [...directories];
}
// Function to get current allowed directories
export function getAllowedDirectories(): string[] {
return [...allowedDirectories];
}
// Type definitions
export interface FileInfo {
size: number;
created: Date;
modified: Date;
accessed: Date;
isDirectory: boolean;
isFile: boolean;
permissions: string;
}
// Pure Utility Functions
export function formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
if (i < 0 || i === 0) return `${bytes} ${units[0]}`;
const unitIndex = Math.min(i, units.length - 1);
return `${(bytes / Math.pow(1024, unitIndex)).toFixed(2)} ${units[unitIndex]}`;
}
// Security & Validation Functions
export async function validatePath(requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath);
// For relative paths, resolve against the first allowed directory
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: path.resolve(allowedDirectories[0] || process.cwd(), expandedPath);
const normalizedRequested = normalizePath(absolute);
// Security: Check if path is within allowed directories before any file operations
const isAllowed = isPathWithinAllowedDirectories(normalizedRequested, allowedDirectories);
if (!isAllowed) {
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
}
// Security: Handle symlinks by checking their real path to prevent symlink attacks
// This prevents attackers from creating symlinks that point outside allowed directories
try {
const realPath = await fs.realpath(absolute);
const normalizedReal = normalizePath(realPath);
if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirectories)) {
throw new Error(`Access denied - symlink target outside allowed directories: ${realPath} not in ${allowedDirectories.join(', ')}`);
}
return realPath;
} catch (error) {
// Security: For new files that don't exist yet, verify parent directory
// This ensures we can't create files in unauthorized locations
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
const parentDir = path.dirname(absolute);
try {
const realParentPath = await fs.realpath(parentDir);
const normalizedParent = normalizePath(realParentPath);
if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) {
throw new Error(`Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(', ')}`);
}
return absolute;
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`);
}
}
throw error;
}
}
// File Operations
export async function getFileStats(filePath: string): Promise<FileInfo> {
const stats = await fs.stat(filePath);
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
accessed: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3),
};
}
export async function readFileContent(filePath: string, encoding: string = 'utf-8'): Promise<string> {
return await fs.readFile(filePath, encoding as BufferEncoding);
}
// Line counting utility
export function countLines(content: string): number {
if (content.length === 0) return 0;
let lines = 1;
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n') lines++;
}
return lines;
}
// Detect likely language from file extension
export function detectLanguage(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const langMap: Record<string, string> = {
'.js': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.py': 'python',
'.cs': 'csharp',
'.java': 'java',
'.cpp': 'cpp',
'.c': 'c',
'.h': 'c',
'.hpp': 'cpp',
'.go': 'go',
'.rs': 'rust',
'.rb': 'ruby',
'.php': 'php',
'.swift': 'swift',
'.kt': 'kotlin',
'.scala': 'scala',
'.sh': 'shell',
'.bash': 'shell',
'.json': 'json',
'.xml': 'xml',
'.html': 'html',
'.css': 'css',
'.sql': 'sql',
'.md': 'markdown',
'.yaml': 'yaml',
'.yml': 'yaml',
};
return langMap[ext] || 'text';
}
// Check if file is likely binary
export async function isBinaryFile(filePath: string): Promise<boolean> {
try {
const buffer = Buffer.alloc(512);
const fd = await fs.open(filePath, 'r');
try {
const { bytesRead } = await fd.read(buffer, 0, 512, 0);
// Check for null bytes which indicate binary content
for (let i = 0; i < bytesRead; i++) {
if (buffer[i] === 0) {
return true;
}
}
return false;
} finally {
await fd.close();
}
} catch {
return false;
}
}
// List directory with metadata
export interface DirectoryEntry {
name: string;
type: 'file' | 'directory';
size: string | null;
lines?: number;
itemCount?: number;
}
export interface DirectoryListing {
path: string;
entries: DirectoryEntry[];
summary: {
totalFiles: number;
totalDirectories: number;
totalSize: string;
};
}
export async function listDirectory(dirPath: string): Promise<DirectoryListing> {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const result: DirectoryEntry[] = [];
let totalFiles = 0;
let totalDirs = 0;
let totalBytes = 0;
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalDirs++;
try {
const subEntries = await fs.readdir(fullPath);
result.push({
name: entry.name,
type: 'directory',
size: null,
itemCount: subEntries.length
});
} catch {
result.push({
name: entry.name,
type: 'directory',
size: null,
itemCount: 0
});
}
} else if (entry.isFile()) {
totalFiles++;
try {
const stats = await fs.stat(fullPath);
totalBytes += stats.size;
// For text files, try to count lines
let lines: number | undefined;
if (stats.size < 10 * 1024 * 1024) { // Only for files < 10MB
const isBinary = await isBinaryFile(fullPath);
if (!isBinary) {
try {
const content = await readFileContent(fullPath);
lines = countLines(content);
} catch {
// Ignore errors reading file content
}
}
}
result.push({
name: entry.name,
type: 'file',
size: formatSize(stats.size),
lines
});
} catch {
result.push({
name: entry.name,
type: 'file',
size: '0 B'
});
}
}
}
return {
path: dirPath,
entries: result,
summary: {
totalFiles,
totalDirectories: totalDirs,
totalSize: formatSize(totalBytes)
}
};
}