Added filesystem module
This commit is contained in:
147
mcpServer/modules/filesystem/__tests__/directory-tree.test.ts
Normal file
147
mcpServer/modules/filesystem/__tests__/directory-tree.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// We need to test the buildTree function, but it's defined inside the request handler
|
||||
// So we'll extract the core logic into a testable function
|
||||
import { minimatch } from 'minimatch';
|
||||
|
||||
interface TreeEntry {
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: TreeEntry[];
|
||||
}
|
||||
|
||||
async function buildTreeForTesting(currentPath: string, rootPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
|
||||
const entries = await fs.readdir(currentPath, {withFileTypes: true});
|
||||
const result: TreeEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
|
||||
const shouldExclude = excludePatterns.some(pattern => {
|
||||
if (pattern.includes('*')) {
|
||||
return minimatch(relativePath, pattern, {dot: true});
|
||||
}
|
||||
// For files: match exact name or as part of path
|
||||
// For directories: match as directory path
|
||||
return minimatch(relativePath, pattern, {dot: true}) ||
|
||||
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
|
||||
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
|
||||
});
|
||||
if (shouldExclude)
|
||||
continue;
|
||||
|
||||
const entryData: TreeEntry = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
};
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subPath = path.join(currentPath, entry.name);
|
||||
entryData.children = await buildTreeForTesting(subPath, rootPath, excludePatterns);
|
||||
}
|
||||
|
||||
result.push(entryData);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('buildTree exclude patterns', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-test-'));
|
||||
|
||||
// Create test directory structure
|
||||
await fs.mkdir(path.join(testDir, 'src'));
|
||||
await fs.mkdir(path.join(testDir, 'node_modules'));
|
||||
await fs.mkdir(path.join(testDir, '.git'));
|
||||
await fs.mkdir(path.join(testDir, 'nested', 'node_modules'), { recursive: true });
|
||||
|
||||
// Create test files
|
||||
await fs.writeFile(path.join(testDir, '.env'), 'SECRET=value');
|
||||
await fs.writeFile(path.join(testDir, '.env.local'), 'LOCAL_SECRET=value');
|
||||
await fs.writeFile(path.join(testDir, 'src', 'index.js'), 'console.log("hello");');
|
||||
await fs.writeFile(path.join(testDir, 'package.json'), '{}');
|
||||
await fs.writeFile(path.join(testDir, 'node_modules', 'module.js'), 'module.exports = {};');
|
||||
await fs.writeFile(path.join(testDir, 'nested', 'node_modules', 'deep.js'), 'module.exports = {};');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should exclude files matching simple patterns', async () => {
|
||||
// Test the current implementation - this will fail until the bug is fixed
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['.env']);
|
||||
const fileNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(fileNames).not.toContain('.env');
|
||||
expect(fileNames).toContain('.env.local'); // Should not exclude this
|
||||
expect(fileNames).toContain('src');
|
||||
expect(fileNames).toContain('package.json');
|
||||
});
|
||||
|
||||
it('should exclude directories matching simple patterns', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
|
||||
const dirNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(dirNames).not.toContain('node_modules');
|
||||
expect(dirNames).toContain('src');
|
||||
expect(dirNames).toContain('.git');
|
||||
});
|
||||
|
||||
it('should exclude nested directories with same pattern', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
|
||||
|
||||
// Find the nested directory
|
||||
const nestedDir = tree.find(entry => entry.name === 'nested');
|
||||
expect(nestedDir).toBeDefined();
|
||||
expect(nestedDir!.children).toBeDefined();
|
||||
|
||||
// The nested/node_modules should also be excluded
|
||||
const nestedChildren = nestedDir!.children!.map(child => child.name);
|
||||
expect(nestedChildren).not.toContain('node_modules');
|
||||
});
|
||||
|
||||
it('should handle glob patterns correctly', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['*.env']);
|
||||
const fileNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(fileNames).not.toContain('.env');
|
||||
expect(fileNames).toContain('.env.local'); // *.env should not match .env.local
|
||||
expect(fileNames).toContain('src');
|
||||
});
|
||||
|
||||
it('should handle dot files correctly', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['.git']);
|
||||
const dirNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(dirNames).not.toContain('.git');
|
||||
expect(dirNames).toContain('.env'); // Should not exclude this
|
||||
});
|
||||
|
||||
it('should work with multiple exclude patterns', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules', '.env', '.git']);
|
||||
const entryNames = tree.map(entry => entry.name);
|
||||
|
||||
expect(entryNames).not.toContain('node_modules');
|
||||
expect(entryNames).not.toContain('.env');
|
||||
expect(entryNames).not.toContain('.git');
|
||||
expect(entryNames).toContain('src');
|
||||
expect(entryNames).toContain('package.json');
|
||||
});
|
||||
|
||||
it('should handle empty exclude patterns', async () => {
|
||||
const tree = await buildTreeForTesting(testDir, testDir, []);
|
||||
const entryNames = tree.map(entry => entry.name);
|
||||
|
||||
// All entries should be included
|
||||
expect(entryNames).toContain('node_modules');
|
||||
expect(entryNames).toContain('.env');
|
||||
expect(entryNames).toContain('.git');
|
||||
expect(entryNames).toContain('src');
|
||||
});
|
||||
});
|
||||
725
mcpServer/modules/filesystem/__tests__/lib.test.ts
Normal file
725
mcpServer/modules/filesystem/__tests__/lib.test.ts
Normal file
@@ -0,0 +1,725 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import {
|
||||
// Pure utility functions
|
||||
formatSize,
|
||||
normalizeLineEndings,
|
||||
createUnifiedDiff,
|
||||
// Security & validation functions
|
||||
validatePath,
|
||||
setAllowedDirectories,
|
||||
// File operations
|
||||
getFileStats,
|
||||
readFileContent,
|
||||
writeFileContent,
|
||||
// Search & filtering functions
|
||||
searchFilesWithValidation,
|
||||
// File editing functions
|
||||
applyFileEdits,
|
||||
tailFile,
|
||||
headFile
|
||||
} from '../lib.js';
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('fs/promises');
|
||||
const mockFs = fs as any;
|
||||
|
||||
describe('Lib Functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Set up allowed directories for tests
|
||||
const allowedDirs = process.platform === 'win32' ? ['C:\\Users\\test', 'C:\\temp', 'C:\\allowed'] : ['/home/user', '/tmp', '/allowed'];
|
||||
setAllowedDirectories(allowedDirs);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// Clear allowed directories after tests
|
||||
setAllowedDirectories([]);
|
||||
});
|
||||
|
||||
describe('Pure Utility Functions', () => {
|
||||
describe('formatSize', () => {
|
||||
it('formats bytes correctly', () => {
|
||||
expect(formatSize(0)).toBe('0 B');
|
||||
expect(formatSize(512)).toBe('512 B');
|
||||
expect(formatSize(1024)).toBe('1.00 KB');
|
||||
expect(formatSize(1536)).toBe('1.50 KB');
|
||||
expect(formatSize(1048576)).toBe('1.00 MB');
|
||||
expect(formatSize(1073741824)).toBe('1.00 GB');
|
||||
expect(formatSize(1099511627776)).toBe('1.00 TB');
|
||||
});
|
||||
|
||||
it('handles edge cases', () => {
|
||||
expect(formatSize(1023)).toBe('1023 B');
|
||||
expect(formatSize(1025)).toBe('1.00 KB');
|
||||
expect(formatSize(1048575)).toBe('1024.00 KB');
|
||||
});
|
||||
|
||||
it('handles very large numbers beyond TB', () => {
|
||||
// The function only supports up to TB, so very large numbers will show as TB
|
||||
expect(formatSize(1024 * 1024 * 1024 * 1024 * 1024)).toBe('1024.00 TB');
|
||||
expect(formatSize(Number.MAX_SAFE_INTEGER)).toContain('TB');
|
||||
});
|
||||
|
||||
it('handles negative numbers', () => {
|
||||
// Negative numbers will result in NaN for the log calculation
|
||||
expect(formatSize(-1024)).toContain('NaN');
|
||||
expect(formatSize(-0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('handles decimal numbers', () => {
|
||||
expect(formatSize(1536.5)).toBe('1.50 KB');
|
||||
expect(formatSize(1023.9)).toBe('1023.9 B');
|
||||
});
|
||||
|
||||
it('handles very small positive numbers', () => {
|
||||
expect(formatSize(1)).toBe('1 B');
|
||||
expect(formatSize(0.5)).toBe('0.5 B');
|
||||
expect(formatSize(0.1)).toBe('0.1 B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeLineEndings', () => {
|
||||
it('converts CRLF to LF', () => {
|
||||
expect(normalizeLineEndings('line1\r\nline2\r\nline3')).toBe('line1\nline2\nline3');
|
||||
});
|
||||
|
||||
it('leaves LF unchanged', () => {
|
||||
expect(normalizeLineEndings('line1\nline2\nline3')).toBe('line1\nline2\nline3');
|
||||
});
|
||||
|
||||
it('handles mixed line endings', () => {
|
||||
expect(normalizeLineEndings('line1\r\nline2\nline3\r\n')).toBe('line1\nline2\nline3\n');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(normalizeLineEndings('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUnifiedDiff', () => {
|
||||
it('creates diff for simple changes', () => {
|
||||
const original = 'line1\nline2\nline3';
|
||||
const modified = 'line1\nmodified line2\nline3';
|
||||
const diff = createUnifiedDiff(original, modified, 'test.txt');
|
||||
|
||||
expect(diff).toContain('--- test.txt');
|
||||
expect(diff).toContain('+++ test.txt');
|
||||
expect(diff).toContain('-line2');
|
||||
expect(diff).toContain('+modified line2');
|
||||
});
|
||||
|
||||
it('handles CRLF normalization', () => {
|
||||
const original = 'line1\r\nline2\r\n';
|
||||
const modified = 'line1\nmodified line2\n';
|
||||
const diff = createUnifiedDiff(original, modified);
|
||||
|
||||
expect(diff).toContain('-line2');
|
||||
expect(diff).toContain('+modified line2');
|
||||
});
|
||||
|
||||
it('handles identical content', () => {
|
||||
const content = 'line1\nline2\nline3';
|
||||
const diff = createUnifiedDiff(content, content);
|
||||
|
||||
// Should not contain any +/- lines for identical content (excluding header lines)
|
||||
expect(diff.split('\n').filter((line: string) => line.startsWith('+++') || line.startsWith('---'))).toHaveLength(2);
|
||||
expect(diff.split('\n').filter((line: string) => line.startsWith('+') && !line.startsWith('+++'))).toHaveLength(0);
|
||||
expect(diff.split('\n').filter((line: string) => line.startsWith('-') && !line.startsWith('---'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles empty content', () => {
|
||||
const diff = createUnifiedDiff('', '');
|
||||
expect(diff).toContain('--- file');
|
||||
expect(diff).toContain('+++ file');
|
||||
});
|
||||
|
||||
it('handles default filename parameter', () => {
|
||||
const diff = createUnifiedDiff('old', 'new');
|
||||
expect(diff).toContain('--- file');
|
||||
expect(diff).toContain('+++ file');
|
||||
});
|
||||
|
||||
it('handles custom filename', () => {
|
||||
const diff = createUnifiedDiff('old', 'new', 'custom.txt');
|
||||
expect(diff).toContain('--- custom.txt');
|
||||
expect(diff).toContain('+++ custom.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security & Validation Functions', () => {
|
||||
describe('validatePath', () => {
|
||||
// Use Windows-compatible paths for testing
|
||||
const allowedDirs = process.platform === 'win32' ? ['C:\\Users\\test', 'C:\\temp'] : ['/home/user', '/tmp'];
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs.realpath.mockImplementation(async (path: any) => path.toString());
|
||||
});
|
||||
|
||||
it('validates allowed paths', async () => {
|
||||
const testPath = process.platform === 'win32' ? 'C:\\Users\\test\\file.txt' : '/home/user/file.txt';
|
||||
const result = await validatePath(testPath);
|
||||
expect(result).toBe(testPath);
|
||||
});
|
||||
|
||||
it('rejects disallowed paths', async () => {
|
||||
const testPath = process.platform === 'win32' ? 'C:\\Windows\\System32\\file.txt' : '/etc/passwd';
|
||||
await expect(validatePath(testPath))
|
||||
.rejects.toThrow('Access denied - path outside allowed directories');
|
||||
});
|
||||
|
||||
it('handles non-existent files by checking parent directory', async () => {
|
||||
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\newfile.txt' : '/home/user/newfile.txt';
|
||||
const parentPath = process.platform === 'win32' ? 'C:\\Users\\test' : '/home/user';
|
||||
|
||||
// Create an error with the ENOENT code that the implementation checks for
|
||||
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
mockFs.realpath
|
||||
.mockRejectedValueOnce(enoentError)
|
||||
.mockResolvedValueOnce(parentPath);
|
||||
|
||||
const result = await validatePath(newFilePath);
|
||||
expect(result).toBe(path.resolve(newFilePath));
|
||||
});
|
||||
|
||||
it('rejects when parent directory does not exist', async () => {
|
||||
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\newfile.txt' : '/home/user/nonexistent/newfile.txt';
|
||||
|
||||
// Create errors with the ENOENT code
|
||||
const enoentError1 = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError1.code = 'ENOENT';
|
||||
const enoentError2 = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoentError2.code = 'ENOENT';
|
||||
|
||||
mockFs.realpath
|
||||
.mockRejectedValueOnce(enoentError1)
|
||||
.mockRejectedValueOnce(enoentError2);
|
||||
|
||||
await expect(validatePath(newFilePath))
|
||||
.rejects.toThrow('Parent directory does not exist');
|
||||
});
|
||||
|
||||
it('resolves relative paths against allowed directories instead of process.cwd()', async () => {
|
||||
const relativePath = 'test-file.txt';
|
||||
const originalCwd = process.cwd;
|
||||
|
||||
// Mock process.cwd to return a directory outside allowed directories
|
||||
const disallowedCwd = process.platform === 'win32' ? 'C:\\Windows\\System32' : '/root';
|
||||
(process as any).cwd = vi.fn(() => disallowedCwd);
|
||||
|
||||
try {
|
||||
const result = await validatePath(relativePath);
|
||||
|
||||
// Result should be resolved against first allowed directory, not process.cwd()
|
||||
const expectedPath = process.platform === 'win32'
|
||||
? path.resolve('C:\\Users\\test', relativePath)
|
||||
: path.resolve('/home/user', relativePath);
|
||||
|
||||
expect(result).toBe(expectedPath);
|
||||
expect(result).not.toContain(disallowedCwd);
|
||||
} finally {
|
||||
// Restore original process.cwd
|
||||
process.cwd = originalCwd;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Operations', () => {
|
||||
describe('getFileStats', () => {
|
||||
it('returns file statistics', async () => {
|
||||
const mockStats = {
|
||||
size: 1024,
|
||||
birthtime: new Date('2023-01-01'),
|
||||
mtime: new Date('2023-01-02'),
|
||||
atime: new Date('2023-01-03'),
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
mode: 0o644
|
||||
};
|
||||
|
||||
mockFs.stat.mockResolvedValueOnce(mockStats as any);
|
||||
|
||||
const result = await getFileStats('/test/file.txt');
|
||||
|
||||
expect(result).toEqual({
|
||||
size: 1024,
|
||||
created: new Date('2023-01-01'),
|
||||
modified: new Date('2023-01-02'),
|
||||
accessed: new Date('2023-01-03'),
|
||||
isDirectory: false,
|
||||
isFile: true,
|
||||
permissions: '644'
|
||||
});
|
||||
});
|
||||
|
||||
it('handles directory statistics', async () => {
|
||||
const mockStats = {
|
||||
size: 4096,
|
||||
birthtime: new Date('2023-01-01'),
|
||||
mtime: new Date('2023-01-02'),
|
||||
atime: new Date('2023-01-03'),
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
mode: 0o755
|
||||
};
|
||||
|
||||
mockFs.stat.mockResolvedValueOnce(mockStats as any);
|
||||
|
||||
const result = await getFileStats('/test/dir');
|
||||
|
||||
expect(result.isDirectory).toBe(true);
|
||||
expect(result.isFile).toBe(false);
|
||||
expect(result.permissions).toBe('755');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFileContent', () => {
|
||||
it('reads file with default encoding', async () => {
|
||||
mockFs.readFile.mockResolvedValueOnce('file content');
|
||||
|
||||
const result = await readFileContent('/test/file.txt');
|
||||
|
||||
expect(result).toBe('file content');
|
||||
expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8');
|
||||
});
|
||||
|
||||
it('reads file with custom encoding', async () => {
|
||||
mockFs.readFile.mockResolvedValueOnce('file content');
|
||||
|
||||
const result = await readFileContent('/test/file.txt', 'ascii');
|
||||
|
||||
expect(result).toBe('file content');
|
||||
expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'ascii');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeFileContent', () => {
|
||||
it('writes file content', async () => {
|
||||
mockFs.writeFile.mockResolvedValueOnce(undefined);
|
||||
|
||||
await writeFileContent('/test/file.txt', 'new content');
|
||||
|
||||
expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' });
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Search & Filtering Functions', () => {
|
||||
describe('searchFilesWithValidation', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.realpath.mockImplementation(async (path: any) => path.toString());
|
||||
});
|
||||
|
||||
|
||||
it('excludes files matching exclude patterns', async () => {
|
||||
const mockEntries = [
|
||||
{ name: 'test.txt', isDirectory: () => false },
|
||||
{ name: 'test.log', isDirectory: () => false },
|
||||
{ name: 'node_modules', isDirectory: () => true }
|
||||
];
|
||||
|
||||
mockFs.readdir.mockResolvedValueOnce(mockEntries as any);
|
||||
|
||||
const testDir = process.platform === 'win32' ? 'C:\\allowed\\dir' : '/allowed/dir';
|
||||
const allowedDirs = process.platform === 'win32' ? ['C:\\allowed'] : ['/allowed'];
|
||||
|
||||
// Mock realpath to return the same path for validation to pass
|
||||
mockFs.realpath.mockImplementation(async (inputPath: any) => {
|
||||
const pathStr = inputPath.toString();
|
||||
// Return the path as-is for validation
|
||||
return pathStr;
|
||||
});
|
||||
|
||||
const result = await searchFilesWithValidation(
|
||||
testDir,
|
||||
'*test*',
|
||||
allowedDirs,
|
||||
{ excludePatterns: ['*.log', 'node_modules'] }
|
||||
);
|
||||
|
||||
const expectedResult = process.platform === 'win32' ? 'C:\\allowed\\dir\\test.txt' : '/allowed/dir/test.txt';
|
||||
expect(result).toEqual([expectedResult]);
|
||||
});
|
||||
|
||||
it('handles validation errors during search', async () => {
|
||||
const mockEntries = [
|
||||
{ name: 'test.txt', isDirectory: () => false },
|
||||
{ name: 'invalid_file.txt', isDirectory: () => false }
|
||||
];
|
||||
|
||||
mockFs.readdir.mockResolvedValueOnce(mockEntries as any);
|
||||
|
||||
// Mock validatePath to throw error for invalid_file.txt
|
||||
mockFs.realpath.mockImplementation(async (path: any) => {
|
||||
if (path.toString().includes('invalid_file.txt')) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
return path.toString();
|
||||
});
|
||||
|
||||
const testDir = process.platform === 'win32' ? 'C:\\allowed\\dir' : '/allowed/dir';
|
||||
const allowedDirs = process.platform === 'win32' ? ['C:\\allowed'] : ['/allowed'];
|
||||
|
||||
const result = await searchFilesWithValidation(
|
||||
testDir,
|
||||
'*test*',
|
||||
allowedDirs,
|
||||
{}
|
||||
);
|
||||
|
||||
// Should only return the valid file, skipping the invalid one
|
||||
const expectedResult = process.platform === 'win32' ? 'C:\\allowed\\dir\\test.txt' : '/allowed/dir/test.txt';
|
||||
expect(result).toEqual([expectedResult]);
|
||||
});
|
||||
|
||||
it('handles complex exclude patterns with wildcards', async () => {
|
||||
const mockEntries = [
|
||||
{ name: 'test.txt', isDirectory: () => false },
|
||||
{ name: 'test.backup', isDirectory: () => false },
|
||||
{ name: 'important_test.js', isDirectory: () => false }
|
||||
];
|
||||
|
||||
mockFs.readdir.mockResolvedValueOnce(mockEntries as any);
|
||||
|
||||
const testDir = process.platform === 'win32' ? 'C:\\allowed\\dir' : '/allowed/dir';
|
||||
const allowedDirs = process.platform === 'win32' ? ['C:\\allowed'] : ['/allowed'];
|
||||
|
||||
const result = await searchFilesWithValidation(
|
||||
testDir,
|
||||
'*test*',
|
||||
allowedDirs,
|
||||
{ excludePatterns: ['*.backup'] }
|
||||
);
|
||||
|
||||
const expectedResults = process.platform === 'win32' ? [
|
||||
'C:\\allowed\\dir\\test.txt',
|
||||
'C:\\allowed\\dir\\important_test.js'
|
||||
] : [
|
||||
'/allowed/dir/test.txt',
|
||||
'/allowed/dir/important_test.js'
|
||||
];
|
||||
expect(result).toEqual(expectedResults);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Editing Functions', () => {
|
||||
describe('applyFileEdits', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.readFile.mockResolvedValue('line1\nline2\nline3\n');
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('applies simple text replacement', async () => {
|
||||
const edits = [
|
||||
{ oldText: 'line2', newText: 'modified line2' }
|
||||
];
|
||||
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await applyFileEdits('/test/file.txt', edits, false);
|
||||
|
||||
expect(result).toContain('modified line2');
|
||||
// Should write to temporary file then rename
|
||||
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
|
||||
'line1\nmodified line2\nline3\n',
|
||||
'utf-8'
|
||||
);
|
||||
expect(mockFs.rename).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
|
||||
'/test/file.txt'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles dry run mode', async () => {
|
||||
const edits = [
|
||||
{ oldText: 'line2', newText: 'modified line2' }
|
||||
];
|
||||
|
||||
const result = await applyFileEdits('/test/file.txt', edits, true);
|
||||
|
||||
expect(result).toContain('modified line2');
|
||||
expect(mockFs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies multiple edits sequentially', async () => {
|
||||
const edits = [
|
||||
{ oldText: 'line1', newText: 'first line' },
|
||||
{ oldText: 'line3', newText: 'third line' }
|
||||
];
|
||||
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
|
||||
await applyFileEdits('/test/file.txt', edits, false);
|
||||
|
||||
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
|
||||
'first line\nline2\nthird line\n',
|
||||
'utf-8'
|
||||
);
|
||||
expect(mockFs.rename).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
|
||||
'/test/file.txt'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles whitespace-flexible matching', async () => {
|
||||
mockFs.readFile.mockResolvedValue(' line1\n line2\n line3\n');
|
||||
|
||||
const edits = [
|
||||
{ oldText: 'line2', newText: 'modified line2' }
|
||||
];
|
||||
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
|
||||
await applyFileEdits('/test/file.txt', edits, false);
|
||||
|
||||
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
|
||||
' line1\n modified line2\n line3\n',
|
||||
'utf-8'
|
||||
);
|
||||
expect(mockFs.rename).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
|
||||
'/test/file.txt'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error for non-matching edits', async () => {
|
||||
const edits = [
|
||||
{ oldText: 'nonexistent line', newText: 'replacement' }
|
||||
];
|
||||
|
||||
await expect(applyFileEdits('/test/file.txt', edits, false))
|
||||
.rejects.toThrow('Could not find exact match for edit');
|
||||
});
|
||||
|
||||
it('handles complex multi-line edits with indentation', async () => {
|
||||
mockFs.readFile.mockResolvedValue('function test() {\n console.log("hello");\n return true;\n}');
|
||||
|
||||
const edits = [
|
||||
{
|
||||
oldText: ' console.log("hello");\n return true;',
|
||||
newText: ' console.log("world");\n console.log("test");\n return false;'
|
||||
}
|
||||
];
|
||||
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
|
||||
await applyFileEdits('/test/file.js', edits, false);
|
||||
|
||||
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
|
||||
'function test() {\n console.log("world");\n console.log("test");\n return false;\n}',
|
||||
'utf-8'
|
||||
);
|
||||
expect(mockFs.rename).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
|
||||
'/test/file.js'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles edits with different indentation patterns', async () => {
|
||||
mockFs.readFile.mockResolvedValue(' if (condition) {\n doSomething();\n }');
|
||||
|
||||
const edits = [
|
||||
{
|
||||
oldText: 'doSomething();',
|
||||
newText: 'doSomethingElse();\n doAnotherThing();'
|
||||
}
|
||||
];
|
||||
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
|
||||
await applyFileEdits('/test/file.js', edits, false);
|
||||
|
||||
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
|
||||
' if (condition) {\n doSomethingElse();\n doAnotherThing();\n }',
|
||||
'utf-8'
|
||||
);
|
||||
expect(mockFs.rename).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
|
||||
'/test/file.js'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles CRLF line endings in file content', async () => {
|
||||
mockFs.readFile.mockResolvedValue('line1\r\nline2\r\nline3\r\n');
|
||||
|
||||
const edits = [
|
||||
{ oldText: 'line2', newText: 'modified line2' }
|
||||
];
|
||||
|
||||
mockFs.rename.mockResolvedValueOnce(undefined);
|
||||
|
||||
await applyFileEdits('/test/file.txt', edits, false);
|
||||
|
||||
expect(mockFs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
|
||||
'line1\nmodified line2\nline3\n',
|
||||
'utf-8'
|
||||
);
|
||||
expect(mockFs.rename).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
|
||||
'/test/file.txt'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailFile', () => {
|
||||
it('handles empty files', async () => {
|
||||
mockFs.stat.mockResolvedValue({ size: 0 } as any);
|
||||
|
||||
const result = await tailFile('/test/empty.txt', 5);
|
||||
|
||||
expect(result).toBe('');
|
||||
expect(mockFs.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls stat to check file size', async () => {
|
||||
mockFs.stat.mockResolvedValue({ size: 100 } as any);
|
||||
|
||||
// Mock file handle with proper typing
|
||||
const mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
close: vi.fn()
|
||||
} as any;
|
||||
|
||||
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
|
||||
mockFileHandle.close.mockResolvedValue(undefined);
|
||||
|
||||
mockFs.open.mockResolvedValue(mockFileHandle);
|
||||
|
||||
await tailFile('/test/file.txt', 2);
|
||||
|
||||
expect(mockFs.stat).toHaveBeenCalledWith('/test/file.txt');
|
||||
expect(mockFs.open).toHaveBeenCalledWith('/test/file.txt', 'r');
|
||||
});
|
||||
|
||||
it('handles files with content and returns last lines', async () => {
|
||||
mockFs.stat.mockResolvedValue({ size: 50 } as any);
|
||||
|
||||
const mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
close: vi.fn()
|
||||
} as any;
|
||||
|
||||
// Simulate reading file content in chunks
|
||||
mockFileHandle.read
|
||||
.mockResolvedValueOnce({ bytesRead: 20, buffer: Buffer.from('line3\nline4\nline5\n') })
|
||||
.mockResolvedValueOnce({ bytesRead: 0 });
|
||||
mockFileHandle.close.mockResolvedValue(undefined);
|
||||
|
||||
mockFs.open.mockResolvedValue(mockFileHandle);
|
||||
|
||||
const result = await tailFile('/test/file.txt', 2);
|
||||
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles read errors gracefully', async () => {
|
||||
mockFs.stat.mockResolvedValue({ size: 100 } as any);
|
||||
|
||||
const mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
close: vi.fn()
|
||||
} as any;
|
||||
|
||||
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
|
||||
mockFileHandle.close.mockResolvedValue(undefined);
|
||||
|
||||
mockFs.open.mockResolvedValue(mockFileHandle);
|
||||
|
||||
await tailFile('/test/file.txt', 5);
|
||||
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('headFile', () => {
|
||||
it('opens file for reading', async () => {
|
||||
// Mock file handle with proper typing
|
||||
const mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
close: vi.fn()
|
||||
} as any;
|
||||
|
||||
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
|
||||
mockFileHandle.close.mockResolvedValue(undefined);
|
||||
|
||||
mockFs.open.mockResolvedValue(mockFileHandle);
|
||||
|
||||
await headFile('/test/file.txt', 2);
|
||||
|
||||
expect(mockFs.open).toHaveBeenCalledWith('/test/file.txt', 'r');
|
||||
});
|
||||
|
||||
it('handles files with content and returns first lines', async () => {
|
||||
const mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
close: vi.fn()
|
||||
} as any;
|
||||
|
||||
// Simulate reading file content with newlines
|
||||
mockFileHandle.read
|
||||
.mockResolvedValueOnce({ bytesRead: 20, buffer: Buffer.from('line1\nline2\nline3\n') })
|
||||
.mockResolvedValueOnce({ bytesRead: 0 });
|
||||
mockFileHandle.close.mockResolvedValue(undefined);
|
||||
|
||||
mockFs.open.mockResolvedValue(mockFileHandle);
|
||||
|
||||
const result = await headFile('/test/file.txt', 2);
|
||||
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles files with leftover content', async () => {
|
||||
const mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
close: vi.fn()
|
||||
} as any;
|
||||
|
||||
// Simulate reading file content without final newline
|
||||
mockFileHandle.read
|
||||
.mockResolvedValueOnce({ bytesRead: 15, buffer: Buffer.from('line1\nline2\nend') })
|
||||
.mockResolvedValueOnce({ bytesRead: 0 });
|
||||
mockFileHandle.close.mockResolvedValue(undefined);
|
||||
|
||||
mockFs.open.mockResolvedValue(mockFileHandle);
|
||||
|
||||
const result = await headFile('/test/file.txt', 5);
|
||||
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles reaching requested line count', async () => {
|
||||
const mockFileHandle = {
|
||||
read: vi.fn(),
|
||||
close: vi.fn()
|
||||
} as any;
|
||||
|
||||
// Simulate reading exactly the requested number of lines
|
||||
mockFileHandle.read
|
||||
.mockResolvedValueOnce({ bytesRead: 12, buffer: Buffer.from('line1\nline2\n') })
|
||||
.mockResolvedValueOnce({ bytesRead: 0 });
|
||||
mockFileHandle.close.mockResolvedValue(undefined);
|
||||
|
||||
mockFs.open.mockResolvedValue(mockFileHandle);
|
||||
|
||||
const result = await headFile('/test/file.txt', 2);
|
||||
|
||||
expect(mockFileHandle.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
371
mcpServer/modules/filesystem/__tests__/path-utils.test.ts
Normal file
371
mcpServer/modules/filesystem/__tests__/path-utils.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { normalizePath, expandHome, convertToWindowsPath } from '../path-utils.js';
|
||||
|
||||
describe('Path Utilities', () => {
|
||||
describe('convertToWindowsPath', () => {
|
||||
it('leaves Unix paths unchanged', () => {
|
||||
expect(convertToWindowsPath('/usr/local/bin'))
|
||||
.toBe('/usr/local/bin');
|
||||
expect(convertToWindowsPath('/home/user/some path'))
|
||||
.toBe('/home/user/some path');
|
||||
});
|
||||
|
||||
it('never converts WSL paths (they work correctly in WSL with Node.js fs)', () => {
|
||||
// WSL paths should NEVER be converted, regardless of platform
|
||||
// They are valid Linux paths that work with Node.js fs operations inside WSL
|
||||
expect(convertToWindowsPath('/mnt/c/NS/MyKindleContent'))
|
||||
.toBe('/mnt/c/NS/MyKindleContent');
|
||||
expect(convertToWindowsPath('/mnt/d/Documents'))
|
||||
.toBe('/mnt/d/Documents');
|
||||
});
|
||||
|
||||
it('converts Unix-style Windows paths only on Windows platform', () => {
|
||||
// On Windows, /c/ style paths should be converted
|
||||
if (process.platform === 'win32') {
|
||||
expect(convertToWindowsPath('/c/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
} else {
|
||||
// On Linux, leave them unchanged
|
||||
expect(convertToWindowsPath('/c/NS/MyKindleContent'))
|
||||
.toBe('/c/NS/MyKindleContent');
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves Windows paths unchanged but ensures backslashes', () => {
|
||||
expect(convertToWindowsPath('C:\\NS\\MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
expect(convertToWindowsPath('C:/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('handles Windows paths with spaces', () => {
|
||||
expect(convertToWindowsPath('C:\\Program Files\\Some App'))
|
||||
.toBe('C:\\Program Files\\Some App');
|
||||
expect(convertToWindowsPath('C:/Program Files/Some App'))
|
||||
.toBe('C:\\Program Files\\Some App');
|
||||
});
|
||||
|
||||
it('handles drive letter paths based on platform', () => {
|
||||
// WSL paths should never be converted
|
||||
expect(convertToWindowsPath('/mnt/d/some/path'))
|
||||
.toBe('/mnt/d/some/path');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, Unix-style paths like /d/ should be converted
|
||||
expect(convertToWindowsPath('/d/some/path'))
|
||||
.toBe('D:\\some\\path');
|
||||
} else {
|
||||
// On Linux, /d/ is just a regular Unix path
|
||||
expect(convertToWindowsPath('/d/some/path'))
|
||||
.toBe('/d/some/path');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('preserves Unix paths', () => {
|
||||
expect(normalizePath('/usr/local/bin'))
|
||||
.toBe('/usr/local/bin');
|
||||
expect(normalizePath('/home/user/some path'))
|
||||
.toBe('/home/user/some path');
|
||||
expect(normalizePath('"/usr/local/some app/"'))
|
||||
.toBe('/usr/local/some app');
|
||||
expect(normalizePath('/usr/local//bin/app///'))
|
||||
.toBe('/usr/local/bin/app');
|
||||
expect(normalizePath('/'))
|
||||
.toBe('/');
|
||||
expect(normalizePath('///'))
|
||||
.toBe('/');
|
||||
});
|
||||
|
||||
it('removes surrounding quotes', () => {
|
||||
expect(normalizePath('"C:\\NS\\My Kindle Content"'))
|
||||
.toBe('C:\\NS\\My Kindle Content');
|
||||
});
|
||||
|
||||
it('normalizes backslashes', () => {
|
||||
expect(normalizePath('C:\\\\NS\\\\MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('converts forward slashes to backslashes on Windows', () => {
|
||||
expect(normalizePath('C:/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
});
|
||||
|
||||
it('always preserves WSL paths (they work correctly in WSL)', () => {
|
||||
// WSL paths should ALWAYS be preserved, regardless of platform
|
||||
// This is the fix for issue #2795
|
||||
expect(normalizePath('/mnt/c/NS/MyKindleContent'))
|
||||
.toBe('/mnt/c/NS/MyKindleContent');
|
||||
expect(normalizePath('/mnt/d/Documents'))
|
||||
.toBe('/mnt/d/Documents');
|
||||
});
|
||||
|
||||
it('handles Unix-style Windows paths', () => {
|
||||
// On Windows, /c/ paths should be converted
|
||||
if (process.platform === 'win32') {
|
||||
expect(normalizePath('/c/NS/MyKindleContent'))
|
||||
.toBe('C:\\NS\\MyKindleContent');
|
||||
} else if (process.platform === 'linux') {
|
||||
// On Linux, /c/ is just a regular Unix path
|
||||
expect(normalizePath('/c/NS/MyKindleContent'))
|
||||
.toBe('/c/NS/MyKindleContent');
|
||||
}
|
||||
});
|
||||
|
||||
it('handles paths with spaces and mixed slashes', () => {
|
||||
expect(normalizePath('C:/NS/My Kindle Content'))
|
||||
.toBe('C:\\NS\\My Kindle Content');
|
||||
// WSL paths should always be preserved
|
||||
expect(normalizePath('/mnt/c/NS/My Kindle Content'))
|
||||
.toBe('/mnt/c/NS/My Kindle Content');
|
||||
expect(normalizePath('C:\\Program Files (x86)\\App Name'))
|
||||
.toBe('C:\\Program Files (x86)\\App Name');
|
||||
expect(normalizePath('"C:\\Program Files\\App Name"'))
|
||||
.toBe('C:\\Program Files\\App Name');
|
||||
expect(normalizePath(' C:\\Program Files\\App Name '))
|
||||
.toBe('C:\\Program Files\\App Name');
|
||||
});
|
||||
|
||||
it('preserves spaces in all path formats', () => {
|
||||
// WSL paths should always be preserved
|
||||
expect(normalizePath('/mnt/c/Program Files/App Name'))
|
||||
.toBe('/mnt/c/Program Files/App Name');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, Unix-style paths like /c/ should be converted
|
||||
expect(normalizePath('/c/Program Files/App Name'))
|
||||
.toBe('C:\\Program Files\\App Name');
|
||||
} else {
|
||||
// On Linux, /c/ is just a regular Unix path
|
||||
expect(normalizePath('/c/Program Files/App Name'))
|
||||
.toBe('/c/Program Files/App Name');
|
||||
}
|
||||
expect(normalizePath('C:/Program Files/App Name'))
|
||||
.toBe('C:\\Program Files\\App Name');
|
||||
});
|
||||
|
||||
it('handles special characters in paths', () => {
|
||||
// Test ampersand in path
|
||||
expect(normalizePath('C:\\NS\\Sub&Folder'))
|
||||
.toBe('C:\\NS\\Sub&Folder');
|
||||
expect(normalizePath('C:/NS/Sub&Folder'))
|
||||
.toBe('C:\\NS\\Sub&Folder');
|
||||
// WSL paths should always be preserved
|
||||
expect(normalizePath('/mnt/c/NS/Sub&Folder'))
|
||||
.toBe('/mnt/c/NS/Sub&Folder');
|
||||
|
||||
// Test tilde in path (short names in Windows)
|
||||
expect(normalizePath('C:\\NS\\MYKIND~1'))
|
||||
.toBe('C:\\NS\\MYKIND~1');
|
||||
expect(normalizePath('/Users/NEMANS~1/FOLDER~2/SUBFO~1/Public/P12PST~1'))
|
||||
.toBe('/Users/NEMANS~1/FOLDER~2/SUBFO~1/Public/P12PST~1');
|
||||
|
||||
// Test other special characters
|
||||
expect(normalizePath('C:\\Path with #hash'))
|
||||
.toBe('C:\\Path with #hash');
|
||||
expect(normalizePath('C:\\Path with (parentheses)'))
|
||||
.toBe('C:\\Path with (parentheses)');
|
||||
expect(normalizePath('C:\\Path with [brackets]'))
|
||||
.toBe('C:\\Path with [brackets]');
|
||||
expect(normalizePath('C:\\Path with @at+plus$dollar%percent'))
|
||||
.toBe('C:\\Path with @at+plus$dollar%percent');
|
||||
});
|
||||
|
||||
it('capitalizes lowercase drive letters for Windows paths', () => {
|
||||
expect(normalizePath('c:/windows/system32'))
|
||||
.toBe('C:\\windows\\system32');
|
||||
// WSL paths should always be preserved
|
||||
expect(normalizePath('/mnt/d/my/folder'))
|
||||
.toBe('/mnt/d/my/folder');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, Unix-style paths should be converted and capitalized
|
||||
expect(normalizePath('/e/another/folder'))
|
||||
.toBe('E:\\another\\folder');
|
||||
} else {
|
||||
// On Linux, /e/ is just a regular Unix path
|
||||
expect(normalizePath('/e/another/folder'))
|
||||
.toBe('/e/another/folder');
|
||||
}
|
||||
});
|
||||
|
||||
it('handles UNC paths correctly', () => {
|
||||
// UNC paths should preserve the leading double backslash
|
||||
const uncPath = '\\\\SERVER\\share\\folder';
|
||||
expect(normalizePath(uncPath)).toBe('\\\\SERVER\\share\\folder');
|
||||
|
||||
// Test UNC path with double backslashes that need normalization
|
||||
const uncPathWithDoubles = '\\\\\\\\SERVER\\\\share\\\\folder';
|
||||
expect(normalizePath(uncPathWithDoubles)).toBe('\\\\SERVER\\share\\folder');
|
||||
});
|
||||
|
||||
it('returns normalized non-Windows/WSL/Unix-style Windows paths as is after basic normalization', () => {
|
||||
// A path that looks somewhat absolute but isn't a drive or recognized Unix root for Windows conversion
|
||||
// These paths should be preserved as-is (not converted to Windows C:\ format or WSL format)
|
||||
const otherAbsolutePath = '\\someserver\\share\\file';
|
||||
expect(normalizePath(otherAbsolutePath)).toBe(otherAbsolutePath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandHome', () => {
|
||||
it('expands ~ to home directory', () => {
|
||||
const result = expandHome('~/test');
|
||||
expect(result).toContain('test');
|
||||
expect(result).not.toContain('~');
|
||||
});
|
||||
|
||||
it('expands bare ~ to home directory', () => {
|
||||
const result = expandHome('~');
|
||||
expect(result).not.toContain('~');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('leaves other paths unchanged', () => {
|
||||
expect(expandHome('C:/test')).toBe('C:/test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WSL path handling (issue #2795 fix)', () => {
|
||||
// Save original platform
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
// Restore platform after each test
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should NEVER convert WSL paths - they work correctly in WSL with Node.js fs', () => {
|
||||
// The key insight: When running `wsl npx ...`, Node.js runs INSIDE WSL (process.platform === 'linux')
|
||||
// and /mnt/c/ paths work correctly with Node.js fs operations in that environment.
|
||||
// Converting them to C:\ format breaks fs operations because Windows paths don't work inside WSL.
|
||||
|
||||
// Mock Linux platform (inside WSL)
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// WSL paths should NOT be converted, even inside WSL
|
||||
expect(normalizePath('/mnt/c/Users/username/folder'))
|
||||
.toBe('/mnt/c/Users/username/folder');
|
||||
|
||||
expect(normalizePath('/mnt/d/Documents/project'))
|
||||
.toBe('/mnt/d/Documents/project');
|
||||
});
|
||||
|
||||
it('should also preserve WSL paths when running on Windows', () => {
|
||||
// Mock Windows platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// WSL paths should still be preserved (though they wouldn't be accessible from Windows Node.js)
|
||||
expect(normalizePath('/mnt/c/Users/username/folder'))
|
||||
.toBe('/mnt/c/Users/username/folder');
|
||||
|
||||
expect(normalizePath('/mnt/d/Documents/project'))
|
||||
.toBe('/mnt/d/Documents/project');
|
||||
});
|
||||
|
||||
it('should convert Unix-style Windows paths (/c/) only when running on Windows (win32)', () => {
|
||||
// Mock process.platform to be 'win32' (Windows)
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Unix-style Windows paths like /c/ should be converted on Windows
|
||||
expect(normalizePath('/c/Users/username/folder'))
|
||||
.toBe('C:\\Users\\username\\folder');
|
||||
|
||||
expect(normalizePath('/d/Documents/project'))
|
||||
.toBe('D:\\Documents\\project');
|
||||
});
|
||||
|
||||
it('should NOT convert Unix-style paths (/c/) when running inside WSL (linux)', () => {
|
||||
// Mock process.platform to be 'linux' (WSL/Linux)
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// When on Linux, /c/ is just a regular Unix directory, not a drive letter
|
||||
expect(normalizePath('/c/some/path'))
|
||||
.toBe('/c/some/path');
|
||||
|
||||
expect(normalizePath('/d/another/path'))
|
||||
.toBe('/d/another/path');
|
||||
});
|
||||
|
||||
it('should preserve regular Unix paths on all platforms', () => {
|
||||
// Test on Linux
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(normalizePath('/home/user/documents'))
|
||||
.toBe('/home/user/documents');
|
||||
|
||||
expect(normalizePath('/var/log/app'))
|
||||
.toBe('/var/log/app');
|
||||
|
||||
// Test on Windows (though these paths wouldn't work on Windows)
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(normalizePath('/home/user/documents'))
|
||||
.toBe('/home/user/documents');
|
||||
|
||||
expect(normalizePath('/var/log/app'))
|
||||
.toBe('/var/log/app');
|
||||
});
|
||||
|
||||
it('reproduces exact scenario from issue #2795', () => {
|
||||
// Simulate running inside WSL: wsl npx @modelcontextprotocol/server-filesystem /mnt/c/Users/username/folder
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// This is the exact path from the issue
|
||||
const inputPath = '/mnt/c/Users/username/folder';
|
||||
const result = normalizePath(inputPath);
|
||||
|
||||
// Should NOT convert to C:\Users\username\folder
|
||||
expect(result).toBe('/mnt/c/Users/username/folder');
|
||||
expect(result).not.toContain('C:');
|
||||
expect(result).not.toContain('\\');
|
||||
});
|
||||
|
||||
it('should handle relative path slash conversion based on platform', () => {
|
||||
// This test verifies platform-specific behavior naturally without mocking
|
||||
// On Windows: forward slashes converted to backslashes
|
||||
// On Linux/Unix: forward slashes preserved
|
||||
const relativePath = 'some/relative/path';
|
||||
const result = normalizePath(relativePath);
|
||||
|
||||
if (originalPlatform === 'win32') {
|
||||
expect(result).toBe('some\\relative\\path');
|
||||
} else {
|
||||
expect(result).toBe('some/relative/path');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
953
mcpServer/modules/filesystem/__tests__/path-validation.test.ts
Normal file
953
mcpServer/modules/filesystem/__tests__/path-validation.test.ts
Normal file
@@ -0,0 +1,953 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import { isPathWithinAllowedDirectories } from '../path-validation.js';
|
||||
|
||||
/**
|
||||
* Check if the current environment supports symlink creation
|
||||
*/
|
||||
async function checkSymlinkSupport(): Promise<boolean> {
|
||||
const testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'symlink-test-'));
|
||||
try {
|
||||
const targetFile = path.join(testDir, 'target.txt');
|
||||
const linkFile = path.join(testDir, 'link.txt');
|
||||
|
||||
await fs.writeFile(targetFile, 'test');
|
||||
await fs.symlink(targetFile, linkFile);
|
||||
|
||||
// If we get here, symlinks are supported
|
||||
return true;
|
||||
} catch (error) {
|
||||
// EPERM indicates no symlink permissions
|
||||
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
|
||||
return false;
|
||||
}
|
||||
// Other errors might indicate a real problem
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Global variable to store symlink support status
|
||||
let symlinkSupported: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Get cached symlink support status, checking once per test run
|
||||
*/
|
||||
async function getSymlinkSupport(): Promise<boolean> {
|
||||
if (symlinkSupported === null) {
|
||||
symlinkSupported = await checkSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log('\n⚠️ Symlink tests will be skipped - symlink creation not supported in this environment');
|
||||
console.log(' On Windows, enable Developer Mode or run as Administrator to enable symlink tests');
|
||||
}
|
||||
}
|
||||
return symlinkSupported;
|
||||
}
|
||||
|
||||
describe('Path Validation', () => {
|
||||
it('allows exact directory match', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows subdirectories', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src/index.js', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/deeply/nested/file.txt', allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks similar directory names (prefix vulnerability)', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project_backup', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project-old', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/projectile', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project.bak', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks paths outside allowed directories', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/etc/passwd', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles multiple allowed directories', () => {
|
||||
const allowed = ['/home/user/project1', '/home/user/project2'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project1/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project3', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project1_backup', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2-old', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks parent and sibling directories', () => {
|
||||
const allowed = ['/test/allowed'];
|
||||
|
||||
// Parent directory
|
||||
expect(isPathWithinAllowedDirectories('/test', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/', allowed)).toBe(false);
|
||||
|
||||
// Sibling with common prefix
|
||||
expect(isPathWithinAllowedDirectories('/test/allowed_sibling', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/test/allowed2', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles paths with special characters', () => {
|
||||
const allowed = ['/home/user/my-project (v2)'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my-project (v2)_backup', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my-project', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
describe('Input validation', () => {
|
||||
it('rejects empty inputs', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('handles trailing separators correctly', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Path with trailing separator should still match
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/', allowed)).toBe(true);
|
||||
|
||||
// Allowed directory with trailing separator
|
||||
const allowedWithSep = ['/home/user/project/'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowedWithSep)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/', allowedWithSep)).toBe(true);
|
||||
|
||||
// Should still block similar names with or without trailing separators
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2', allowedWithSep)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2/', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips empty directory entries in allowed list', () => {
|
||||
const allowed = ['', '/home/user/project', ''];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true);
|
||||
|
||||
// Should still validate properly with empty entries
|
||||
expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles Windows paths with trailing separators', () => {
|
||||
if (path.sep === '\\') {
|
||||
const allowed = ['C:\\Users\\project'];
|
||||
|
||||
// Path with trailing separator
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project\\', allowed)).toBe(true);
|
||||
|
||||
// Allowed with trailing separator
|
||||
const allowedWithSep = ['C:\\Users\\project\\'];
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project', allowedWithSep)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project\\', allowedWithSep)).toBe(true);
|
||||
|
||||
// Should still block similar names
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project2\\', allowed)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('normalizes relative paths to absolute', () => {
|
||||
const allowed = [process.cwd()];
|
||||
|
||||
// Relative paths get normalized to absolute paths based on cwd
|
||||
expect(isPathWithinAllowedDirectories('relative/path', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('./file', allowed)).toBe(true);
|
||||
|
||||
// Parent directory references that escape allowed directory
|
||||
const parentAllowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('../parent', parentAllowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for relative paths in allowed directories', () => {
|
||||
const badAllowed = ['relative/path', '/some/other/absolute/path'];
|
||||
|
||||
// Relative paths in allowed dirs are normalized to absolute based on cwd
|
||||
// The normalized 'relative/path' won't match our test path
|
||||
expect(isPathWithinAllowedDirectories('/some/other/absolute/path/file', badAllowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/absolute/path/file', badAllowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles null and undefined inputs gracefully', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Should return false, not crash
|
||||
expect(isPathWithinAllowedDirectories(null as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories(undefined as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/path', null as any)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/path', undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unicode and special characters', () => {
|
||||
it('handles unicode characters in paths', () => {
|
||||
const allowed = ['/home/user/café'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/café', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/café/file', allowed)).toBe(true);
|
||||
|
||||
// Different unicode representation won't match (not normalized)
|
||||
const decomposed = '/home/user/cafe\u0301'; // e + combining accent
|
||||
expect(isPathWithinAllowedDirectories(decomposed, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles paths with spaces correctly', () => {
|
||||
const allowed = ['/home/user/my project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my project/file', allowed)).toBe(true);
|
||||
|
||||
// Partial matches should fail
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/my proj', allowed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overlapping allowed directories', () => {
|
||||
it('handles nested allowed directories correctly', () => {
|
||||
const allowed = ['/home', '/home/user', '/home/user/project'];
|
||||
|
||||
// All paths under /home are allowed
|
||||
expect(isPathWithinAllowedDirectories('/home/anything', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/anything', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/anything', allowed)).toBe(true);
|
||||
|
||||
// First match wins (most permissive)
|
||||
expect(isPathWithinAllowedDirectories('/home/other/deep/path', allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles root directory as allowed', () => {
|
||||
const allowed = ['/'];
|
||||
|
||||
// Everything is allowed under root (dangerous configuration)
|
||||
expect(isPathWithinAllowedDirectories('/', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/any/path', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/etc/passwd', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/secret', allowed)).toBe(true);
|
||||
|
||||
// But only on the same filesystem root
|
||||
if (path.sep === '\\') {
|
||||
expect(isPathWithinAllowedDirectories('D:\\other', ['/'])).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-platform behavior', () => {
|
||||
it('handles Windows-style paths on Windows', () => {
|
||||
if (path.sep === '\\') {
|
||||
const allowed = ['C:\\Users\\project'];
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project\\src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project2', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users\\project_backup', allowed)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles Unix-style paths on Unix', () => {
|
||||
if (path.sep === '/') {
|
||||
const allowed = ['/home/user/project'];
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project2', allowed)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation Tests - Path Traversal', () => {
|
||||
it('blocks path traversal attempts', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Basic traversal attempts
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../../../etc/passwd', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../../other', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../project2', allowed)).toBe(false);
|
||||
|
||||
// Mixed traversal with valid segments
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/src/../../project2', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/./../../other', allowed)).toBe(false);
|
||||
|
||||
// Multiple traversal sequences
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../project/../../../etc', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks traversal in allowed directories', () => {
|
||||
const allowed = ['/home/user/project/../safe'];
|
||||
|
||||
// The allowed directory itself should be normalized and safe
|
||||
expect(isPathWithinAllowedDirectories('/home/user/safe/file', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles complex traversal patterns', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Double dots in filenames (not traversal) - these normalize to paths within allowed dir
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/..test', allowed)).toBe(true); // Not traversal
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/test..', allowed)).toBe(true); // Not traversal
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/te..st', allowed)).toBe(true); // Not traversal
|
||||
|
||||
// Actual traversal
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/../test', allowed)).toBe(false); // Is traversal - goes to /home/user/test
|
||||
|
||||
// Edge case: /home/user/project/.. normalizes to /home/user (parent dir)
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/..', allowed)).toBe(false); // Goes to parent
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation Tests - Null Bytes', () => {
|
||||
it('rejects paths with null bytes', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project\x00/etc/passwd', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/test\x00.txt', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('\x00/home/user/project', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/\x00', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects allowed directories with null bytes', () => {
|
||||
const allowed = ['/home/user/project\x00'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation Tests - Special Characters', () => {
|
||||
it('allows percent signs in filenames', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Percent is a valid filename character
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/report_50%.pdf', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/Q1_25%_growth', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/%41', allowed)).toBe(true); // File named %41
|
||||
|
||||
// URL encoding is NOT decoded by path.normalize, so these are just odd filenames
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/%2e%2e', allowed)).toBe(true); // File named "%2e%2e"
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/file%20name', allowed)).toBe(true); // File with %20 in name
|
||||
});
|
||||
|
||||
it('handles percent signs in allowed directories', () => {
|
||||
const allowed = ['/home/user/project%20files'];
|
||||
|
||||
// This is a directory literally named "project%20files"
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project%20files/test', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project files/test', allowed)).toBe(false); // Different dir
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Normalization', () => {
|
||||
it('normalizes paths before comparison', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Trailing slashes
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project//', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project///', allowed)).toBe(true);
|
||||
|
||||
// Current directory references
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/./src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/./project/src', allowed)).toBe(true);
|
||||
|
||||
// Multiple slashes
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project//src//file', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home//user//project//src', allowed)).toBe(true);
|
||||
|
||||
// Should still block outside paths
|
||||
expect(isPathWithinAllowedDirectories('/home/user//project2', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles mixed separators correctly', () => {
|
||||
if (path.sep === '\\') {
|
||||
const allowed = ['C:\\Users\\project'];
|
||||
|
||||
// Mixed separators should be normalized
|
||||
expect(isPathWithinAllowedDirectories('C:/Users/project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:\\Users/project\\src', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('C:/Users\\project/src', allowed)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('rejects non-string inputs safely', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories(123 as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories({} as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories([] as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories(null as any, allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories(undefined as any, allowed)).toBe(false);
|
||||
|
||||
// Non-string in allowed directories
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', [123 as any])).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project', [{} as any])).toBe(false);
|
||||
});
|
||||
|
||||
it('handles very long paths', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Create a very long path that's still valid
|
||||
const longSubPath = 'a/'.repeat(1000) + 'file.txt';
|
||||
expect(isPathWithinAllowedDirectories(`/home/user/project/${longSubPath}`, allowed)).toBe(true);
|
||||
|
||||
// Very long path that escapes
|
||||
const escapePath = 'a/'.repeat(1000) + '../'.repeat(1001) + 'etc/passwd';
|
||||
expect(isPathWithinAllowedDirectories(`/home/user/project/${escapePath}`, allowed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Additional Coverage', () => {
|
||||
it('handles allowed directories with traversal that normalizes safely', () => {
|
||||
// These allowed dirs contain traversal but normalize to valid paths
|
||||
const allowed = ['/home/user/../user/project'];
|
||||
|
||||
// Should normalize to /home/user/project and work correctly
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/file', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/other', allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles symbolic dots in filenames', () => {
|
||||
const allowed = ['/home/user/project'];
|
||||
|
||||
// Single and double dots as actual filenames (not traversal)
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/.', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/..', allowed)).toBe(false); // This normalizes to parent
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/...', allowed)).toBe(true); // Three dots is a valid filename
|
||||
expect(isPathWithinAllowedDirectories('/home/user/project/....', allowed)).toBe(true); // Four dots is a valid filename
|
||||
});
|
||||
|
||||
it('handles UNC paths on Windows', () => {
|
||||
if (path.sep === '\\') {
|
||||
const allowed = ['\\\\server\\share\\project'];
|
||||
|
||||
expect(isPathWithinAllowedDirectories('\\\\server\\share\\project', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('\\\\server\\share\\project\\file', allowed)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories('\\\\server\\share\\other', allowed)).toBe(false);
|
||||
expect(isPathWithinAllowedDirectories('\\\\other\\share\\project', allowed)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Symlink Tests', () => {
|
||||
let testDir: string;
|
||||
let allowedDir: string;
|
||||
let forbiddenDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-error-test-'));
|
||||
allowedDir = path.join(testDir, 'allowed');
|
||||
forbiddenDir = path.join(testDir, 'forbidden');
|
||||
|
||||
await fs.mkdir(allowedDir, { recursive: true });
|
||||
await fs.mkdir(forbiddenDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('validates symlink handling', async () => {
|
||||
// Test with symlinks
|
||||
try {
|
||||
const linkPath = path.join(allowedDir, 'bad-link');
|
||||
const targetPath = path.join(forbiddenDir, 'target.txt');
|
||||
|
||||
await fs.writeFile(targetPath, 'content');
|
||||
await fs.symlink(targetPath, linkPath);
|
||||
|
||||
// In real implementation, this would throw with the resolved path
|
||||
const realPath = await fs.realpath(linkPath);
|
||||
const allowed = [allowedDir];
|
||||
|
||||
// Symlink target should be outside allowed directory
|
||||
expect(isPathWithinAllowedDirectories(realPath, allowed)).toBe(false);
|
||||
} catch (error) {
|
||||
// Skip if no symlink permissions
|
||||
}
|
||||
});
|
||||
|
||||
it('handles non-existent paths correctly', async () => {
|
||||
const newFilePath = path.join(allowedDir, 'subdir', 'newfile.txt');
|
||||
|
||||
// Parent directory doesn't exist
|
||||
try {
|
||||
await fs.access(newFilePath);
|
||||
} catch (error) {
|
||||
expect((error as NodeJS.ErrnoException).code).toBe('ENOENT');
|
||||
}
|
||||
|
||||
// After creating parent, validation should work
|
||||
await fs.mkdir(path.dirname(newFilePath), { recursive: true });
|
||||
const allowed = [allowedDir];
|
||||
expect(isPathWithinAllowedDirectories(newFilePath, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
// Test path resolution consistency for symlinked files
|
||||
it('validates symlinked files consistently between path and resolved forms', async () => {
|
||||
try {
|
||||
// Setup: Create target file in forbidden area
|
||||
const targetFile = path.join(forbiddenDir, 'target.txt');
|
||||
await fs.writeFile(targetFile, 'TARGET_CONTENT');
|
||||
|
||||
// Create symlink inside allowed directory pointing to forbidden file
|
||||
const symlinkPath = path.join(allowedDir, 'link-to-target.txt');
|
||||
await fs.symlink(targetFile, symlinkPath);
|
||||
|
||||
// The symlink path itself passes validation (looks like it's in allowed dir)
|
||||
expect(isPathWithinAllowedDirectories(symlinkPath, [allowedDir])).toBe(true);
|
||||
|
||||
// But the resolved path should fail validation
|
||||
const resolvedPath = await fs.realpath(symlinkPath);
|
||||
expect(isPathWithinAllowedDirectories(resolvedPath, [allowedDir])).toBe(false);
|
||||
|
||||
// Verify the resolved path goes to the forbidden location (normalize both paths for macOS temp dirs)
|
||||
expect(await fs.realpath(resolvedPath)).toBe(await fs.realpath(targetFile));
|
||||
} catch (error) {
|
||||
// Skip if no symlink permissions on the system
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EPERM') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test allowed directory resolution behavior
|
||||
it('validates paths correctly when allowed directory is resolved from symlink', async () => {
|
||||
try {
|
||||
// Setup: Create the actual target directory with content
|
||||
const actualTargetDir = path.join(testDir, 'actual-target');
|
||||
await fs.mkdir(actualTargetDir, { recursive: true });
|
||||
const targetFile = path.join(actualTargetDir, 'file.txt');
|
||||
await fs.writeFile(targetFile, 'FILE_CONTENT');
|
||||
|
||||
// Setup: Create symlink directory that points to target
|
||||
const symlinkDir = path.join(testDir, 'symlink-dir');
|
||||
await fs.symlink(actualTargetDir, symlinkDir);
|
||||
|
||||
// Simulate resolved allowed directory (what the server startup should do)
|
||||
const resolvedAllowedDir = await fs.realpath(symlinkDir);
|
||||
const resolvedTargetDir = await fs.realpath(actualTargetDir);
|
||||
expect(resolvedAllowedDir).toBe(resolvedTargetDir);
|
||||
|
||||
// Test 1: File access through original symlink path should pass validation with resolved allowed dir
|
||||
const fileViaSymlink = path.join(symlinkDir, 'file.txt');
|
||||
const resolvedFile = await fs.realpath(fileViaSymlink);
|
||||
expect(isPathWithinAllowedDirectories(resolvedFile, [resolvedAllowedDir])).toBe(true);
|
||||
|
||||
// Test 2: File access through resolved path should also pass validation
|
||||
const fileViaResolved = path.join(resolvedTargetDir, 'file.txt');
|
||||
expect(isPathWithinAllowedDirectories(fileViaResolved, [resolvedAllowedDir])).toBe(true);
|
||||
|
||||
// Test 3: Demonstrate inconsistent behavior with unresolved allowed directories
|
||||
// If allowed dirs were not resolved (storing symlink paths instead):
|
||||
const unresolvedAllowedDirs = [symlinkDir];
|
||||
// This validation would incorrectly fail for the same content:
|
||||
expect(isPathWithinAllowedDirectories(resolvedFile, unresolvedAllowedDirs)).toBe(false);
|
||||
|
||||
} catch (error) {
|
||||
// Skip if no symlink permissions on the system
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EPERM') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves nested symlink chains completely', async () => {
|
||||
try {
|
||||
// Setup: Create target file in forbidden area
|
||||
const actualTarget = path.join(forbiddenDir, 'target-file.txt');
|
||||
await fs.writeFile(actualTarget, 'FINAL_CONTENT');
|
||||
|
||||
// Create chain of symlinks: allowedFile -> link2 -> link1 -> actualTarget
|
||||
const link1 = path.join(testDir, 'intermediate-link1');
|
||||
const link2 = path.join(testDir, 'intermediate-link2');
|
||||
const allowedFile = path.join(allowedDir, 'seemingly-safe-file');
|
||||
|
||||
await fs.symlink(actualTarget, link1);
|
||||
await fs.symlink(link1, link2);
|
||||
await fs.symlink(link2, allowedFile);
|
||||
|
||||
// The allowed file path passes basic validation
|
||||
expect(isPathWithinAllowedDirectories(allowedFile, [allowedDir])).toBe(true);
|
||||
|
||||
// But complete resolution reveals the forbidden target
|
||||
const fullyResolvedPath = await fs.realpath(allowedFile);
|
||||
expect(isPathWithinAllowedDirectories(fullyResolvedPath, [allowedDir])).toBe(false);
|
||||
expect(await fs.realpath(fullyResolvedPath)).toBe(await fs.realpath(actualTarget));
|
||||
|
||||
} catch (error) {
|
||||
// Skip if no symlink permissions on the system
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EPERM') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Validation Race Condition Tests', () => {
|
||||
let testDir: string;
|
||||
let allowedDir: string;
|
||||
let forbiddenDir: string;
|
||||
let targetFile: string;
|
||||
let testPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'race-test-'));
|
||||
allowedDir = path.join(testDir, 'allowed');
|
||||
forbiddenDir = path.join(testDir, 'outside');
|
||||
targetFile = path.join(forbiddenDir, 'target.txt');
|
||||
testPath = path.join(allowedDir, 'test.txt');
|
||||
|
||||
await fs.mkdir(allowedDir, { recursive: true });
|
||||
await fs.mkdir(forbiddenDir, { recursive: true });
|
||||
await fs.writeFile(targetFile, 'ORIGINAL CONTENT', 'utf-8');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('validates non-existent file paths based on parent directory', async () => {
|
||||
const allowed = [allowedDir];
|
||||
|
||||
expect(isPathWithinAllowedDirectories(testPath, allowed)).toBe(true);
|
||||
await expect(fs.access(testPath)).rejects.toThrow();
|
||||
|
||||
const parentDir = path.dirname(testPath);
|
||||
expect(isPathWithinAllowedDirectories(parentDir, allowed)).toBe(true);
|
||||
});
|
||||
|
||||
it('demonstrates symlink race condition allows writing outside allowed directories', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping symlink race condition test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
|
||||
await expect(fs.access(testPath)).rejects.toThrow();
|
||||
expect(isPathWithinAllowedDirectories(testPath, allowed)).toBe(true);
|
||||
|
||||
await fs.symlink(targetFile, testPath);
|
||||
await fs.writeFile(testPath, 'MODIFIED CONTENT', 'utf-8');
|
||||
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(targetContent).toBe('MODIFIED CONTENT');
|
||||
|
||||
const resolvedPath = await fs.realpath(testPath);
|
||||
expect(isPathWithinAllowedDirectories(resolvedPath, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows timing differences between validation approaches', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping timing validation test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
|
||||
const validation1 = isPathWithinAllowedDirectories(testPath, allowed);
|
||||
expect(validation1).toBe(true);
|
||||
|
||||
await fs.symlink(targetFile, testPath);
|
||||
|
||||
const resolvedPath = await fs.realpath(testPath);
|
||||
const validation2 = isPathWithinAllowedDirectories(resolvedPath, allowed);
|
||||
expect(validation2).toBe(false);
|
||||
|
||||
expect(validation1).not.toBe(validation2);
|
||||
});
|
||||
|
||||
it('validates directory creation timing', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping directory creation timing test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
const testDir = path.join(allowedDir, 'newdir');
|
||||
|
||||
expect(isPathWithinAllowedDirectories(testDir, allowed)).toBe(true);
|
||||
|
||||
await fs.symlink(forbiddenDir, testDir);
|
||||
|
||||
expect(isPathWithinAllowedDirectories(testDir, allowed)).toBe(true);
|
||||
|
||||
const resolved = await fs.realpath(testDir);
|
||||
expect(isPathWithinAllowedDirectories(resolved, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('demonstrates exclusive file creation behavior', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping exclusive file creation test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
|
||||
await fs.symlink(targetFile, testPath);
|
||||
|
||||
await expect(fs.open(testPath, 'wx')).rejects.toThrow(/EEXIST/);
|
||||
|
||||
await fs.writeFile(testPath, 'NEW CONTENT', 'utf-8');
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(targetContent).toBe('NEW CONTENT');
|
||||
});
|
||||
|
||||
it('should use resolved parent paths for non-existent files', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping resolved parent paths test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
|
||||
const symlinkDir = path.join(allowedDir, 'link');
|
||||
await fs.symlink(forbiddenDir, symlinkDir);
|
||||
|
||||
const fileThroughSymlink = path.join(symlinkDir, 'newfile.txt');
|
||||
|
||||
expect(fileThroughSymlink.startsWith(allowedDir)).toBe(true);
|
||||
|
||||
const parentDir = path.dirname(fileThroughSymlink);
|
||||
const resolvedParent = await fs.realpath(parentDir);
|
||||
expect(isPathWithinAllowedDirectories(resolvedParent, allowed)).toBe(false);
|
||||
|
||||
const expectedSafePath = path.join(resolvedParent, path.basename(fileThroughSymlink));
|
||||
expect(isPathWithinAllowedDirectories(expectedSafePath, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('demonstrates parent directory symlink traversal', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping parent directory symlink traversal test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
const deepPath = path.join(allowedDir, 'sub1', 'sub2', 'file.txt');
|
||||
|
||||
expect(isPathWithinAllowedDirectories(deepPath, allowed)).toBe(true);
|
||||
|
||||
const sub1Path = path.join(allowedDir, 'sub1');
|
||||
await fs.symlink(forbiddenDir, sub1Path);
|
||||
|
||||
await fs.mkdir(path.join(sub1Path, 'sub2'), { recursive: true });
|
||||
await fs.writeFile(deepPath, 'CONTENT', 'utf-8');
|
||||
|
||||
const realPath = await fs.realpath(deepPath);
|
||||
const realAllowedDir = await fs.realpath(allowedDir);
|
||||
const realForbiddenDir = await fs.realpath(forbiddenDir);
|
||||
|
||||
expect(realPath.startsWith(realAllowedDir)).toBe(false);
|
||||
expect(realPath.startsWith(realForbiddenDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent race condition between validatePath and file operation', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping race condition prevention test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
const racePath = path.join(allowedDir, 'race-file.txt');
|
||||
const targetFile = path.join(forbiddenDir, 'target.txt');
|
||||
|
||||
await fs.writeFile(targetFile, 'ORIGINAL CONTENT', 'utf-8');
|
||||
|
||||
// Path validation would pass (file doesn't exist, parent is in allowed dir)
|
||||
expect(await fs.access(racePath).then(() => false).catch(() => true)).toBe(true);
|
||||
expect(isPathWithinAllowedDirectories(racePath, allowed)).toBe(true);
|
||||
|
||||
// Race condition: symlink created after validation but before write
|
||||
await fs.symlink(targetFile, racePath);
|
||||
|
||||
// With exclusive write flag, write should fail on symlink
|
||||
await expect(
|
||||
fs.writeFile(racePath, 'NEW CONTENT', { encoding: 'utf-8', flag: 'wx' })
|
||||
).rejects.toThrow(/EEXIST/);
|
||||
|
||||
// Verify content unchanged
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(targetContent).toBe('ORIGINAL CONTENT');
|
||||
|
||||
// The symlink exists but write was blocked
|
||||
const actualWritePath = await fs.realpath(racePath);
|
||||
expect(actualWritePath).toBe(await fs.realpath(targetFile));
|
||||
expect(isPathWithinAllowedDirectories(actualWritePath, allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow overwrites to legitimate files within allowed directories', async () => {
|
||||
const allowed = [allowedDir];
|
||||
const legitFile = path.join(allowedDir, 'legit-file.txt');
|
||||
|
||||
// Create a legitimate file
|
||||
await fs.writeFile(legitFile, 'ORIGINAL', 'utf-8');
|
||||
|
||||
// Opening with w should work for legitimate files
|
||||
const fd = await fs.open(legitFile, 'w');
|
||||
try {
|
||||
await fd.write('UPDATED', 0, 'utf-8');
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
|
||||
const content = await fs.readFile(legitFile, 'utf-8');
|
||||
expect(content).toBe('UPDATED');
|
||||
});
|
||||
|
||||
it('should handle symlinks that point within allowed directories', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping symlinks within allowed directories test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
const targetFile = path.join(allowedDir, 'target.txt');
|
||||
const symlinkPath = path.join(allowedDir, 'symlink.txt');
|
||||
|
||||
// Create target file within allowed directory
|
||||
await fs.writeFile(targetFile, 'TARGET CONTENT', 'utf-8');
|
||||
|
||||
// Create symlink pointing to allowed file
|
||||
await fs.symlink(targetFile, symlinkPath);
|
||||
|
||||
// Opening symlink with w follows it to the target
|
||||
const fd = await fs.open(symlinkPath, 'w');
|
||||
try {
|
||||
await fd.write('UPDATED VIA SYMLINK', 0, 'utf-8');
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
|
||||
// Both symlink and target should show updated content
|
||||
const symlinkContent = await fs.readFile(symlinkPath, 'utf-8');
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(symlinkContent).toBe('UPDATED VIA SYMLINK');
|
||||
expect(targetContent).toBe('UPDATED VIA SYMLINK');
|
||||
});
|
||||
|
||||
it('should prevent overwriting files through symlinks pointing outside allowed directories', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping symlink overwrite prevention test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
const legitFile = path.join(allowedDir, 'existing.txt');
|
||||
const targetFile = path.join(forbiddenDir, 'target.txt');
|
||||
|
||||
// Create a legitimate file first
|
||||
await fs.writeFile(legitFile, 'LEGIT CONTENT', 'utf-8');
|
||||
|
||||
// Create target file in forbidden directory
|
||||
await fs.writeFile(targetFile, 'FORBIDDEN CONTENT', 'utf-8');
|
||||
|
||||
// Now replace the legitimate file with a symlink to forbidden location
|
||||
await fs.unlink(legitFile);
|
||||
await fs.symlink(targetFile, legitFile);
|
||||
|
||||
// Simulate the server's validation logic
|
||||
const stats = await fs.lstat(legitFile);
|
||||
expect(stats.isSymbolicLink()).toBe(true);
|
||||
|
||||
const realPath = await fs.realpath(legitFile);
|
||||
expect(isPathWithinAllowedDirectories(realPath, allowed)).toBe(false);
|
||||
|
||||
// With atomic rename, symlinks are replaced not followed
|
||||
// So this test now demonstrates the protection
|
||||
|
||||
// Verify content remains unchanged
|
||||
const targetContent = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(targetContent).toBe('FORBIDDEN CONTENT');
|
||||
});
|
||||
|
||||
it('demonstrates race condition in read operations', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping race condition in read operations test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
const legitFile = path.join(allowedDir, 'readable.txt');
|
||||
const secretFile = path.join(forbiddenDir, 'secret.txt');
|
||||
|
||||
// Create legitimate file
|
||||
await fs.writeFile(legitFile, 'PUBLIC CONTENT', 'utf-8');
|
||||
|
||||
// Create secret file in forbidden directory
|
||||
await fs.writeFile(secretFile, 'SECRET CONTENT', 'utf-8');
|
||||
|
||||
// Step 1: validatePath would pass for legitimate file
|
||||
expect(isPathWithinAllowedDirectories(legitFile, allowed)).toBe(true);
|
||||
|
||||
// Step 2: Race condition - replace file with symlink after validation
|
||||
await fs.unlink(legitFile);
|
||||
await fs.symlink(secretFile, legitFile);
|
||||
|
||||
// Step 3: Read operation follows symlink to forbidden location
|
||||
const content = await fs.readFile(legitFile, 'utf-8');
|
||||
|
||||
// This shows the vulnerability - we read forbidden content
|
||||
expect(content).toBe('SECRET CONTENT');
|
||||
expect(isPathWithinAllowedDirectories(await fs.realpath(legitFile), allowed)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies rename does not follow symlinks', async () => {
|
||||
const symlinkSupported = await getSymlinkSupport();
|
||||
if (!symlinkSupported) {
|
||||
console.log(' ⏭️ Skipping rename symlink test - symlinks not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = [allowedDir];
|
||||
const tempFile = path.join(allowedDir, 'temp.txt');
|
||||
const targetSymlink = path.join(allowedDir, 'target-symlink.txt');
|
||||
const forbiddenTarget = path.join(forbiddenDir, 'forbidden-target.txt');
|
||||
|
||||
// Create forbidden target
|
||||
await fs.writeFile(forbiddenTarget, 'ORIGINAL CONTENT', 'utf-8');
|
||||
|
||||
// Create symlink pointing to forbidden location
|
||||
await fs.symlink(forbiddenTarget, targetSymlink);
|
||||
|
||||
// Write temp file
|
||||
await fs.writeFile(tempFile, 'NEW CONTENT', 'utf-8');
|
||||
|
||||
// Rename temp file to symlink path
|
||||
await fs.rename(tempFile, targetSymlink);
|
||||
|
||||
// Check what happened
|
||||
const symlinkExists = await fs.lstat(targetSymlink).then(() => true).catch(() => false);
|
||||
const isSymlink = symlinkExists && (await fs.lstat(targetSymlink)).isSymbolicLink();
|
||||
const targetContent = await fs.readFile(targetSymlink, 'utf-8');
|
||||
const forbiddenContent = await fs.readFile(forbiddenTarget, 'utf-8');
|
||||
|
||||
// Rename should replace the symlink with a regular file
|
||||
expect(isSymlink).toBe(false);
|
||||
expect(targetContent).toBe('NEW CONTENT');
|
||||
expect(forbiddenContent).toBe('ORIGINAL CONTENT'); // Unchanged
|
||||
});
|
||||
});
|
||||
});
|
||||
84
mcpServer/modules/filesystem/__tests__/roots-utils.test.ts
Normal file
84
mcpServer/modules/filesystem/__tests__/roots-utils.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { getValidRootDirectories } from '../roots-utils.js';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, realpathSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import type { Root } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
describe('getValidRootDirectories', () => {
|
||||
let testDir1: string;
|
||||
let testDir2: string;
|
||||
let testDir3: string;
|
||||
let testFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create test directories
|
||||
testDir1 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test1-')));
|
||||
testDir2 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test2-')));
|
||||
testDir3 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test3-')));
|
||||
|
||||
// Create a test file (not a directory)
|
||||
testFile = join(testDir1, 'test-file.txt');
|
||||
writeFileSync(testFile, 'test content');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
rmSync(testDir1, { recursive: true, force: true });
|
||||
rmSync(testDir2, { recursive: true, force: true });
|
||||
rmSync(testDir3, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('valid directory processing', () => {
|
||||
it('should process all URI formats and edge cases', async () => {
|
||||
const roots = [
|
||||
{ uri: `file://${testDir1}`, name: 'File URI' },
|
||||
{ uri: testDir2, name: 'Plain path' },
|
||||
{ uri: testDir3 } // Plain path without name property
|
||||
];
|
||||
|
||||
const result = await getValidRootDirectories(roots);
|
||||
|
||||
expect(result).toContain(testDir1);
|
||||
expect(result).toContain(testDir2);
|
||||
expect(result).toContain(testDir3);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should normalize complex paths', async () => {
|
||||
const subDir = join(testDir1, 'subdir');
|
||||
mkdirSync(subDir);
|
||||
|
||||
const roots = [
|
||||
{ uri: `file://${testDir1}/./subdir/../subdir`, name: 'Complex Path' }
|
||||
];
|
||||
|
||||
const result = await getValidRootDirectories(roots);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(subDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
|
||||
it('should handle various error types', async () => {
|
||||
const nonExistentDir = join(tmpdir(), 'non-existent-directory-12345');
|
||||
const invalidPath = '\0invalid\0path'; // Null bytes cause different error types
|
||||
const roots = [
|
||||
{ uri: `file://${testDir1}`, name: 'Valid Dir' },
|
||||
{ uri: `file://${nonExistentDir}`, name: 'Non-existent Dir' },
|
||||
{ uri: `file://${testFile}`, name: 'File Not Dir' },
|
||||
{ uri: `file://${invalidPath}`, name: 'Invalid Path' }
|
||||
];
|
||||
|
||||
const result = await getValidRootDirectories(roots);
|
||||
|
||||
expect(result).toContain(testDir1);
|
||||
expect(result).not.toContain(nonExistentDir);
|
||||
expect(result).not.toContain(testFile);
|
||||
expect(result).not.toContain(invalidPath);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
|
||||
const SERVER_PATH = path.join(__dirname, '..', 'dist', 'index.js');
|
||||
|
||||
/**
|
||||
* Spawns the filesystem server with given arguments and returns exit info
|
||||
*/
|
||||
async function spawnServer(args: string[], timeoutMs = 2000): Promise<{ exitCode: number | null; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('node', [SERVER_PATH, ...args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
proc.kill('SIGTERM');
|
||||
}, timeoutMs);
|
||||
|
||||
proc.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ exitCode: code, stderr });
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ exitCode: 1, stderr: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('Startup Directory Validation', () => {
|
||||
let testDir: string;
|
||||
let accessibleDir: string;
|
||||
let accessibleDir2: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-startup-test-'));
|
||||
accessibleDir = path.join(testDir, 'accessible');
|
||||
accessibleDir2 = path.join(testDir, 'accessible2');
|
||||
await fs.mkdir(accessibleDir, { recursive: true });
|
||||
await fs.mkdir(accessibleDir2, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should start successfully with all accessible directories', async () => {
|
||||
const result = await spawnServer([accessibleDir, accessibleDir2]);
|
||||
// Server starts and runs (we kill it after timeout, so exit code is null or from SIGTERM)
|
||||
expect(result.stderr).toContain('Secure MCP Filesystem Server running on SSE');
|
||||
expect(result.stderr).not.toContain('Error:');
|
||||
});
|
||||
|
||||
it('should skip inaccessible directory and continue with accessible one', async () => {
|
||||
const nonExistentDir = path.join(testDir, 'non-existent-dir-12345');
|
||||
|
||||
const result = await spawnServer([nonExistentDir, accessibleDir]);
|
||||
|
||||
// Should warn about inaccessible directory
|
||||
expect(result.stderr).toContain('Warning: Cannot access directory');
|
||||
expect(result.stderr).toContain(nonExistentDir);
|
||||
|
||||
// Should still start successfully
|
||||
expect(result.stderr).toContain('Secure MCP Filesystem Server running on SSE');
|
||||
});
|
||||
|
||||
it('should exit with error when ALL directories are inaccessible', async () => {
|
||||
const nonExistent1 = path.join(testDir, 'non-existent-1');
|
||||
const nonExistent2 = path.join(testDir, 'non-existent-2');
|
||||
|
||||
const result = await spawnServer([nonExistent1, nonExistent2]);
|
||||
|
||||
// Should exit with error
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Error: None of the specified directories are accessible');
|
||||
});
|
||||
|
||||
it('should warn when path is not a directory', async () => {
|
||||
const filePath = path.join(testDir, 'not-a-directory.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const result = await spawnServer([filePath, accessibleDir]);
|
||||
|
||||
// Should warn about non-directory
|
||||
expect(result.stderr).toContain('Warning:');
|
||||
expect(result.stderr).toContain('not a directory');
|
||||
|
||||
// Should still start with the valid directory
|
||||
expect(result.stderr).toContain('Secure MCP Filesystem Server running on SSE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
/**
|
||||
* Integration tests to verify that tool handlers return structuredContent
|
||||
* that matches the declared outputSchema.
|
||||
*
|
||||
* These tests address issues #3110, #3106, #3093 where tools were returning
|
||||
* structuredContent: { content: [contentBlock] } (array) instead of
|
||||
* structuredContent: { content: string } as declared in outputSchema.
|
||||
*/
|
||||
describe('structuredContent schema compliance', () => {
|
||||
let client: Client;
|
||||
let transport: StdioClientTransport;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temp directory for testing
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-test-'));
|
||||
|
||||
// Create test files
|
||||
await fs.writeFile(path.join(testDir, 'test.txt'), 'test content');
|
||||
await fs.mkdir(path.join(testDir, 'subdir'));
|
||||
await fs.writeFile(path.join(testDir, 'subdir', 'nested.txt'), 'nested content');
|
||||
|
||||
// Start the MCP server
|
||||
const serverPath = path.resolve(__dirname, '../dist/index.js');
|
||||
transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [serverPath, testDir],
|
||||
});
|
||||
|
||||
client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0',
|
||||
}, {
|
||||
capabilities: {}
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client?.close();
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('directory_tree', () => {
|
||||
it('should return structuredContent.content as a string, not an array', async () => {
|
||||
const result = await client.callTool({
|
||||
name: 'directory_tree',
|
||||
arguments: { path: testDir }
|
||||
});
|
||||
|
||||
// The result should have structuredContent
|
||||
expect(result.structuredContent).toBeDefined();
|
||||
|
||||
// structuredContent.content should be a string (matching outputSchema: { content: z.string() })
|
||||
const structuredContent = result.structuredContent as { content: unknown };
|
||||
expect(typeof structuredContent.content).toBe('string');
|
||||
|
||||
// It should NOT be an array
|
||||
expect(Array.isArray(structuredContent.content)).toBe(false);
|
||||
|
||||
// The content should be valid JSON representing the tree
|
||||
const treeData = JSON.parse(structuredContent.content as string);
|
||||
expect(Array.isArray(treeData)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_directory_with_sizes', () => {
|
||||
it('should return structuredContent.content as a string, not an array', async () => {
|
||||
const result = await client.callTool({
|
||||
name: 'list_directory_with_sizes',
|
||||
arguments: { path: testDir }
|
||||
});
|
||||
|
||||
// The result should have structuredContent
|
||||
expect(result.structuredContent).toBeDefined();
|
||||
|
||||
// structuredContent.content should be a string (matching outputSchema: { content: z.string() })
|
||||
const structuredContent = result.structuredContent as { content: unknown };
|
||||
expect(typeof structuredContent.content).toBe('string');
|
||||
|
||||
// It should NOT be an array
|
||||
expect(Array.isArray(structuredContent.content)).toBe(false);
|
||||
|
||||
// The content should contain directory listing info
|
||||
expect(structuredContent.content).toContain('[FILE]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('move_file', () => {
|
||||
it('should return structuredContent.content as a string, not an array', async () => {
|
||||
const sourcePath = path.join(testDir, 'test.txt');
|
||||
const destPath = path.join(testDir, 'moved.txt');
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'move_file',
|
||||
arguments: {
|
||||
source: sourcePath,
|
||||
destination: destPath
|
||||
}
|
||||
});
|
||||
|
||||
// The result should have structuredContent
|
||||
expect(result.structuredContent).toBeDefined();
|
||||
|
||||
// structuredContent.content should be a string (matching outputSchema: { content: z.string() })
|
||||
const structuredContent = result.structuredContent as { content: unknown };
|
||||
expect(typeof structuredContent.content).toBe('string');
|
||||
|
||||
// It should NOT be an array
|
||||
expect(Array.isArray(structuredContent.content)).toBe(false);
|
||||
|
||||
// The content should contain success message
|
||||
expect(structuredContent.content).toContain('Successfully moved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_directory (control - already working)', () => {
|
||||
it('should return structuredContent.content as a string', async () => {
|
||||
const result = await client.callTool({
|
||||
name: 'list_directory',
|
||||
arguments: { path: testDir }
|
||||
});
|
||||
|
||||
expect(result.structuredContent).toBeDefined();
|
||||
|
||||
const structuredContent = result.structuredContent as { content: unknown };
|
||||
expect(typeof structuredContent.content).toBe('string');
|
||||
expect(Array.isArray(structuredContent.content)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_files (control - already working)', () => {
|
||||
it('should return structuredContent.content as a string', async () => {
|
||||
const result = await client.callTool({
|
||||
name: 'search_files',
|
||||
arguments: {
|
||||
path: testDir,
|
||||
pattern: '*.txt'
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.structuredContent).toBeDefined();
|
||||
|
||||
const structuredContent = result.structuredContent as { content: unknown };
|
||||
expect(typeof structuredContent.content).toBe('string');
|
||||
expect(Array.isArray(structuredContent.content)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user