Skip to content

Commit 60a921d

Browse files
committed
Clean up
1 parent 3e413ff commit 60a921d

2 files changed

Lines changed: 7 additions & 337 deletions

File tree

src/filesystem/index.ts

Lines changed: 3 additions & 277 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,9 @@ import {
1212
import fs from "fs/promises";
1313
import { createReadStream } from "fs";
1414
import path from "path";
15-
import os from 'os';
16-
import { randomBytes } from 'crypto';
1715
import { z } from "zod";
1816
import { zodToJsonSchema } from "zod-to-json-schema";
1917
import { normalizePath, expandHome } from './path-utils.js';
20-
import { isPathWithinAllowedDirectories } from './path-validation.js';
2118
import { getValidRootDirectories } from './roots-utils.js';
2219
import {
2320
// Function imports
@@ -26,11 +23,11 @@ import {
2623
getFileStats,
2724
readFileContent,
2825
writeFileContent,
29-
searchFiles,
26+
searchFilesWithValidation,
3027
applyFileEdits,
3128
tailFile,
3229
headFile,
33-
setAllowedDirectories
30+
setAllowedDirectories,
3431
} from './lib.js';
3532

3633
// Command line argument parsing
@@ -157,277 +154,6 @@ const server = new Server(
157154
},
158155
);
159156

160-
// Tool implementations
161-
async function getFileStats(filePath: string): Promise<FileInfo> {
162-
const stats = await fs.stat(filePath);
163-
return {
164-
size: stats.size,
165-
created: stats.birthtime,
166-
modified: stats.mtime,
167-
accessed: stats.atime,
168-
isDirectory: stats.isDirectory(),
169-
isFile: stats.isFile(),
170-
permissions: stats.mode.toString(8).slice(-3),
171-
};
172-
}
173-
174-
async function searchFiles(
175-
rootPath: string,
176-
pattern: string,
177-
excludePatterns: string[] = []
178-
): Promise<string[]> {
179-
const results: string[] = [];
180-
181-
async function search(currentPath: string) {
182-
const entries = await fs.readdir(currentPath, { withFileTypes: true });
183-
184-
for (const entry of entries) {
185-
const fullPath = path.join(currentPath, entry.name);
186-
187-
try {
188-
// Validate each path before processing
189-
await validatePath(fullPath);
190-
191-
// Check if path matches any exclude pattern
192-
const relativePath = path.relative(rootPath, fullPath);
193-
const shouldExclude = excludePatterns.some(pattern => {
194-
const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**`;
195-
return minimatch(relativePath, globPattern, { dot: true });
196-
});
197-
198-
if (shouldExclude) {
199-
continue;
200-
}
201-
202-
if (entry.name.toLowerCase().includes(pattern.toLowerCase())) {
203-
results.push(fullPath);
204-
}
205-
206-
if (entry.isDirectory()) {
207-
await search(fullPath);
208-
}
209-
} catch (error) {
210-
// Skip invalid paths during search
211-
continue;
212-
}
213-
}
214-
}
215-
216-
await search(rootPath);
217-
return results;
218-
}
219-
220-
// file editing and diffing utilities
221-
function normalizeLineEndings(text: string): string {
222-
return text.replace(/\r\n/g, '\n');
223-
}
224-
225-
function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
226-
// Ensure consistent line endings for diff
227-
const normalizedOriginal = normalizeLineEndings(originalContent);
228-
const normalizedNew = normalizeLineEndings(newContent);
229-
230-
return createTwoFilesPatch(
231-
filepath,
232-
filepath,
233-
normalizedOriginal,
234-
normalizedNew,
235-
'original',
236-
'modified'
237-
);
238-
}
239-
240-
async function applyFileEdits(
241-
filePath: string,
242-
edits: Array<{oldText: string, newText: string}>,
243-
dryRun = false
244-
): Promise<string> {
245-
// Read file content and normalize line endings
246-
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'));
247-
248-
// Apply edits sequentially
249-
let modifiedContent = content;
250-
for (const edit of edits) {
251-
const normalizedOld = normalizeLineEndings(edit.oldText);
252-
const normalizedNew = normalizeLineEndings(edit.newText);
253-
254-
// If exact match exists, use it
255-
if (modifiedContent.includes(normalizedOld)) {
256-
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew);
257-
continue;
258-
}
259-
260-
// Otherwise, try line-by-line matching with flexibility for whitespace
261-
const oldLines = normalizedOld.split('\n');
262-
const contentLines = modifiedContent.split('\n');
263-
let matchFound = false;
264-
265-
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
266-
const potentialMatch = contentLines.slice(i, i + oldLines.length);
267-
268-
// Compare lines with normalized whitespace
269-
const isMatch = oldLines.every((oldLine, j) => {
270-
const contentLine = potentialMatch[j];
271-
return oldLine.trim() === contentLine.trim();
272-
});
273-
274-
if (isMatch) {
275-
// Preserve original indentation of first line
276-
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '';
277-
const newLines = normalizedNew.split('\n').map((line, j) => {
278-
if (j === 0) return originalIndent + line.trimStart();
279-
// For subsequent lines, try to preserve relative indentation
280-
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '';
281-
const newIndent = line.match(/^\s*/)?.[0] || '';
282-
if (oldIndent && newIndent) {
283-
const relativeIndent = newIndent.length - oldIndent.length;
284-
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart();
285-
}
286-
return line;
287-
});
288-
289-
contentLines.splice(i, oldLines.length, ...newLines);
290-
modifiedContent = contentLines.join('\n');
291-
matchFound = true;
292-
break;
293-
}
294-
}
295-
296-
if (!matchFound) {
297-
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
298-
}
299-
}
300-
301-
// Create unified diff
302-
const diff = createUnifiedDiff(content, modifiedContent, filePath);
303-
304-
// Format diff with appropriate number of backticks
305-
let numBackticks = 3;
306-
while (diff.includes('`'.repeat(numBackticks))) {
307-
numBackticks++;
308-
}
309-
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
310-
311-
if (!dryRun) {
312-
// Security: Use atomic rename to prevent race conditions where symlinks
313-
// could be created between validation and write. Rename operations
314-
// replace the target file atomically and don't follow symlinks.
315-
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
316-
try {
317-
await fs.writeFile(tempPath, modifiedContent, 'utf-8');
318-
await fs.rename(tempPath, filePath);
319-
} catch (error) {
320-
try {
321-
await fs.unlink(tempPath);
322-
} catch {}
323-
throw error;
324-
}
325-
}
326-
327-
return formattedDiff;
328-
}
329-
330-
// Helper functions
331-
function formatSize(bytes: number): string {
332-
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
333-
if (bytes === 0) return '0 B';
334-
335-
const i = Math.floor(Math.log(bytes) / Math.log(1024));
336-
if (i === 0) return `${bytes} ${units[i]}`;
337-
338-
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
339-
}
340-
341-
// Memory-efficient implementation to get the last N lines of a file
342-
async function tailFile(filePath: string, numLines: number): Promise<string> {
343-
const CHUNK_SIZE = 1024; // Read 1KB at a time
344-
const stats = await fs.stat(filePath);
345-
const fileSize = stats.size;
346-
347-
if (fileSize === 0) return '';
348-
349-
// Open file for reading
350-
const fileHandle = await fs.open(filePath, 'r');
351-
try {
352-
const lines: string[] = [];
353-
let position = fileSize;
354-
let chunk = Buffer.alloc(CHUNK_SIZE);
355-
let linesFound = 0;
356-
let remainingText = '';
357-
358-
// Read chunks from the end of the file until we have enough lines
359-
while (position > 0 && linesFound < numLines) {
360-
const size = Math.min(CHUNK_SIZE, position);
361-
position -= size;
362-
363-
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
364-
if (!bytesRead) break;
365-
366-
// Get the chunk as a string and prepend any remaining text from previous iteration
367-
const readData = chunk.slice(0, bytesRead).toString('utf-8');
368-
const chunkText = readData + remainingText;
369-
370-
// Split by newlines and count
371-
const chunkLines = normalizeLineEndings(chunkText).split('\n');
372-
373-
// If this isn't the end of the file, the first line is likely incomplete
374-
// Save it to prepend to the next chunk
375-
if (position > 0) {
376-
remainingText = chunkLines[0];
377-
chunkLines.shift(); // Remove the first (incomplete) line
378-
}
379-
380-
// Add lines to our result (up to the number we need)
381-
for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) {
382-
lines.unshift(chunkLines[i]);
383-
linesFound++;
384-
}
385-
}
386-
387-
return lines.join('\n');
388-
} finally {
389-
await fileHandle.close();
390-
}
391-
}
392-
393-
// New function to get the first N lines of a file
394-
async function headFile(filePath: string, numLines: number): Promise<string> {
395-
const fileHandle = await fs.open(filePath, 'r');
396-
try {
397-
const lines: string[] = [];
398-
let buffer = '';
399-
let bytesRead = 0;
400-
const chunk = Buffer.alloc(1024); // 1KB buffer
401-
402-
// Read chunks and count lines until we have enough or reach EOF
403-
while (lines.length < numLines) {
404-
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
405-
if (result.bytesRead === 0) break; // End of file
406-
bytesRead += result.bytesRead;
407-
buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
408-
409-
const newLineIndex = buffer.lastIndexOf('\n');
410-
if (newLineIndex !== -1) {
411-
const completeLines = buffer.slice(0, newLineIndex).split('\n');
412-
buffer = buffer.slice(newLineIndex + 1);
413-
for (const line of completeLines) {
414-
lines.push(line);
415-
if (lines.length >= numLines) break;
416-
}
417-
}
418-
}
419-
420-
// If there is leftover content and we still need lines, add it
421-
if (buffer.length > 0 && lines.length < numLines) {
422-
lines.push(buffer);
423-
}
424-
425-
return lines.join('\n');
426-
} finally {
427-
await fileHandle.close();
428-
}
429-
}
430-
431157
// Reads a file as a stream of buffers, concatenates them, and then encodes
432158
// the result to a Base64 string. This is a memory-efficient way to handle
433159
// binary data from a stream before the final encoding.
@@ -851,7 +577,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
851577
throw new Error(`Invalid arguments for search_files: ${parsed.error}`);
852578
}
853579
const validPath = await validatePath(parsed.data.path);
854-
const results = await searchFiles(validPath, parsed.data.pattern, parsed.data.excludePatterns);
580+
const results = await searchFilesWithValidation(validPath, parsed.data.pattern, allowedDirectories, { excludePatterns: parsed.data.excludePatterns });
855581
return {
856582
content: [{ type: "text", text: results.length > 0 ? results.join("\n") : "No matches found" }],
857583
};

0 commit comments

Comments
 (0)