servers/src/filesystem/lib.ts

477 lines
16 KiB
TypeScript

import fs from "fs/promises";
import path from "path";
import os from 'os';
import { randomBytes } from 'crypto';
import { StringDecoder } from 'string_decoder';
import { diffLines, createTwoFilesPatch } from 'diff';
import { minimatch } from 'minimatch';
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
interface FileInfo {
size: number;
created: Date;
modified: Date;
accessed: Date;
isDirectory: boolean;
isFile: boolean;
permissions: string;
}
export interface SearchOptions {
excludePatterns?: string[];
}
export interface SearchResult {
path: string;
isDirectory: boolean;
}
// 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]}`;
}
export function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n');
}
export function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
// Ensure consistent line endings for diff
const normalizedOriginal = normalizeLineEndings(originalContent);
const normalizedNew = normalizeLineEndings(newContent);
return createTwoFilesPatch(
filepath,
filepath,
normalizedOriginal,
normalizedNew,
'original',
'modified'
);
}
// Helper function to resolve relative paths against allowed directories
function resolveRelativePathAgainstAllowedDirectories(relativePath: string): string {
if (allowedDirectories.length === 0) {
// Fallback to process.cwd() if no allowed directories are set
return path.resolve(process.cwd(), relativePath);
}
// Try to resolve relative path against each allowed directory
for (const allowedDir of allowedDirectories) {
const candidate = path.resolve(allowedDir, relativePath);
const normalizedCandidate = normalizePath(candidate);
// Check if the resulting path lies within any allowed directory
if (isPathWithinAllowedDirectories(normalizedCandidate, allowedDirectories)) {
return candidate;
}
}
// If no valid resolution found, use the first allowed directory as base
// This provides a consistent fallback behavior
return path.resolve(allowedDirectories[0], relativePath);
}
// Security & Validation Functions
async function resolveUnicodeEquivalentPath(absolutePath: string): Promise<string> {
const allowedDirectory = [...allowedDirectories]
.sort((left, right) => right.length - left.length)
.find(directory => isPathWithinAllowedDirectories(normalizePath(absolutePath), [directory]));
if (!allowedDirectory) {
return absolutePath;
}
let currentPath = await fs.realpath(allowedDirectory);
const relativeParts = path.relative(allowedDirectory, absolutePath).split(path.sep).filter(Boolean);
for (let index = 0; index < relativeParts.length; index++) {
const requestedPart = relativeParts[index];
const entries = (await fs.readdir(currentPath)) ?? [];
const exactMatch = entries.find(entry => entry === requestedPart);
const equivalentMatches = exactMatch
? [exactMatch]
: entries.filter(entry => entry.normalize('NFC') === requestedPart.normalize('NFC'));
if (equivalentMatches.length > 1) {
throw new Error(`Ambiguous Unicode path component: ${requestedPart}`);
}
if (equivalentMatches.length === 0) {
if (index === relativeParts.length - 1) {
return path.join(currentPath, requestedPart);
}
throw new Error(`Parent directory does not exist: ${path.join(currentPath, requestedPart)}`);
}
currentPath = await fs.realpath(path.join(currentPath, equivalentMatches[0]));
if (!isPathWithinAllowedDirectories(normalizePath(currentPath), allowedDirectories)) {
throw new Error(`Access denied - symlink target outside allowed directories: ${currentPath} not in ${allowedDirectories.join(', ')}`);
}
}
return currentPath;
}
export async function validatePath(requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath);
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: resolveRelativePathAgainstAllowedDirectories(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') {
try {
return await resolveUnicodeEquivalentPath(absolute);
} catch (resolutionError) {
if ((resolutionError as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Parent directory does not exist: ${path.dirname(absolute)}`);
}
throw resolutionError;
}
}
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);
}
export async function writeFileContent(filePath: string, content: string): Promise<void> {
try {
// Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists,
// preventing writes through pre-existing symlinks
await fs.writeFile(filePath, content, { encoding: "utf-8", flag: 'wx' });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
// Security: Use atomic rename to prevent race conditions where symlinks
// could be created between validation and write. Rename operations
// replace the target file atomically and don't follow symlinks.
const origStats = await fs.stat(filePath);
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
try {
await fs.writeFile(tempPath, content, 'utf-8');
await fs.rename(tempPath, filePath);
} catch (renameError) {
try {
await fs.unlink(tempPath);
} catch {}
throw renameError;
}
// Restore original permission bits since the atomic rename replaces the
// inode and the temp file has default (0644) permissions. Mask off the
// file-type bits; POSIX leaves them unspecified for chmod. A chmod
// failure must not fail the write, which has already succeeded.
try {
await fs.chmod(filePath, origStats.mode & 0o777);
} catch {}
} else {
throw error;
}
}
}
export async function moveFile(sourcePath: string, destinationPath: string): Promise<void> {
// The move_file tool contract (and README) state the operation fails if the
// destination already exists. fs.rename would silently overwrite it, which is
// a data-loss bug, so reject up front when anything - file, directory, or
// symlink - occupies the target. lstat is used so an existing symlink at the
// destination is detected rather than followed.
try {
await fs.lstat(destinationPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
await fs.rename(sourcePath, destinationPath);
return;
}
throw error;
}
throw new Error(`Destination already exists: ${destinationPath}`);
}
// File Editing Functions
interface FileEdit {
oldText: string;
newText: string;
}
export async function applyFileEdits(
filePath: string,
edits: FileEdit[],
dryRun: boolean = false
): Promise<string> {
// Read file content and normalize line endings
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'));
// Apply edits sequentially
let modifiedContent = content;
for (const edit of edits) {
const normalizedOld = normalizeLineEndings(edit.oldText);
const normalizedNew = normalizeLineEndings(edit.newText);
// If exact match exists, use it
if (modifiedContent.includes(normalizedOld)) {
modifiedContent = modifiedContent.replace(normalizedOld, () => normalizedNew);
continue;
}
// Otherwise, try line-by-line matching with flexibility for whitespace
const oldLines = normalizedOld.split('\n');
const contentLines = modifiedContent.split('\n');
let matchFound = false;
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
const potentialMatch = contentLines.slice(i, i + oldLines.length);
// Compare lines with normalized whitespace
const isMatch = oldLines.every((oldLine, j) => {
const contentLine = potentialMatch[j];
return oldLine.trim() === contentLine.trim();
});
if (isMatch) {
// Preserve original indentation of first line
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '';
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0) return originalIndent + line.trimStart();
// For subsequent lines, try to preserve relative indentation
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '';
const newIndent = line.match(/^\s*/)?.[0] || '';
if (oldIndent && newIndent) {
const relativeIndent = newIndent.length - oldIndent.length;
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart();
}
return line;
});
contentLines.splice(i, oldLines.length, ...newLines);
modifiedContent = contentLines.join('\n');
matchFound = true;
break;
}
}
if (!matchFound) {
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
}
}
// Create unified diff
const diff = createUnifiedDiff(content, modifiedContent, filePath);
// Format diff with appropriate number of backticks
let numBackticks = 3;
while (diff.includes('`'.repeat(numBackticks))) {
numBackticks++;
}
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
if (!dryRun) {
// Security: Use atomic rename to prevent race conditions where symlinks
// could be created between validation and write. Rename operations
// replace the target file atomically and don't follow symlinks.
const origStats = await fs.stat(filePath);
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
try {
await fs.writeFile(tempPath, modifiedContent, 'utf-8');
await fs.rename(tempPath, filePath);
} catch (error) {
try {
await fs.unlink(tempPath);
} catch {}
throw error;
}
// Restore original permission bits since the atomic rename replaces the
// inode and the temp file has default (0644) permissions. Mask off the
// file-type bits; POSIX leaves them unspecified for chmod. A chmod
// failure must not fail the write, which has already succeeded.
try {
await fs.chmod(filePath, origStats.mode & 0o777);
} catch {}
}
return formattedDiff;
}
// Memory-efficient implementation to get the last N lines of a file
export async function tailFile(filePath: string, numLines: number): Promise<string> {
const CHUNK_SIZE = 1024; // Read 1KB at a time
const stats = await fs.stat(filePath);
const fileSize = stats.size;
if (fileSize === 0) return '';
// Open file for reading
const fileHandle = await fs.open(filePath, 'r');
try {
const chunks: Buffer[] = [];
let position = fileSize;
const chunk = Buffer.alloc(CHUNK_SIZE);
let newlinesFound = 0;
// Read chunks from the end of the file until we have enough lines
while (position > 0 && newlinesFound < numLines) {
const size = Math.min(CHUNK_SIZE, position);
position -= size;
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
if (!bytesRead) break;
const readData = Buffer.from(chunk.subarray(0, bytesRead));
chunks.unshift(readData);
for (const byte of readData) {
if (byte === 0x0a) newlinesFound++;
}
}
const text = normalizeLineEndings(Buffer.concat(chunks).toString('utf-8'));
return text.split('\n').slice(-numLines).join('\n');
} finally {
await fileHandle.close();
}
}
// New function to get the first N lines of a file
export async function headFile(filePath: string, numLines: number): Promise<string> {
const fileHandle = await fs.open(filePath, 'r');
try {
const lines: string[] = [];
let buffer = '';
let bytesRead = 0;
const chunk = Buffer.alloc(1024); // 1KB buffer
const decoder = new StringDecoder('utf-8');
// Read chunks and count lines until we have enough or reach EOF
while (lines.length < numLines) {
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
if (result.bytesRead === 0) break; // End of file
bytesRead += result.bytesRead;
buffer += decoder.write(chunk.subarray(0, result.bytesRead));
const newLineIndex = buffer.lastIndexOf('\n');
if (newLineIndex !== -1) {
const completeLines = buffer.slice(0, newLineIndex).split('\n');
buffer = buffer.slice(newLineIndex + 1);
for (const line of completeLines) {
lines.push(line);
if (lines.length >= numLines) break;
}
}
}
buffer += decoder.end();
// If there is leftover content and we still need lines, add it
if (buffer.length > 0 && lines.length < numLines) {
lines.push(buffer);
}
return lines.join('\n');
} finally {
await fileHandle.close();
}
}
export async function searchFilesWithValidation(
rootPath: string,
pattern: string,
allowedDirectories: string[],
options: SearchOptions = {}
): Promise<string[]> {
const { excludePatterns = [] } = options;
const results: string[] = [];
async function search(currentPath: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
try {
await validatePath(fullPath);
const relativePath = path.relative(rootPath, fullPath);
const shouldExclude = excludePatterns.some(excludePattern =>
minimatch(relativePath, excludePattern, { dot: true })
);
if (shouldExclude) continue;
// Use glob matching for the search pattern
if (minimatch(relativePath, pattern, { dot: true })) {
results.push(fullPath);
}
if (entry.isDirectory()) {
await search(fullPath);
}
} catch {
continue;
}
}
}
await search(rootPath);
return results;
}