Skip to content
111 changes: 99 additions & 12 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getFileStats,
readFileContent,
writeFileContent,
moveFile,
// Search & filtering functions
searchFilesWithValidation,
// File editing functions
Expand All @@ -26,6 +27,23 @@ import {
vi.mock('fs/promises');
const mockFs = fs as any;

function createMockFileHandle(content: Buffer) {
return {
read: vi.fn(
async (buffer: Buffer, offset: number, length: number, position: number) => {
const bytesRead = content.copy(
buffer,
offset,
position,
Math.min(position + length, content.length),
);
return { bytesRead, buffer };
},
),
close: vi.fn().mockResolvedValue(undefined),
};
}

describe('Lib Functions', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -190,19 +208,34 @@ describe('Lib Functions', () => {
expect(result).toBe(path.resolve(newFilePath));
});

it('rejects when parent directory does not exist', async () => {
it('walks past multiple missing ancestors to an existing allowed directory', async () => {
// e.g. create_directory('/home/user/nonexistent/nested/newfile.txt') when
// neither 'nonexistent' nor 'nonexistent/nested' exist yet, but '/home/user'
// (an allowed directory) does. Regression test for #4629.
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\nested\\newfile.txt' : '/home/user/nonexistent/nested/newfile.txt';

const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError.code = 'ENOENT';

// The path itself is missing. The resolver then realpaths the allowed
// directory ('/home/user'), which falls through to the beforeEach base
// implementation and echoes the path back as-is. readdir is mocked to
// undefined, so every component below it counts as missing.
mockFs.realpath.mockRejectedValueOnce(enoentError);

const result = await validatePath(newFilePath);
expect(result).toBe(path.resolve(newFilePath));
});

it('rejects when no ancestor directory exists at all', 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);


const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError.code = 'ENOENT';

// Every ancestor, all the way up to the filesystem root, is missing.
mockFs.realpath.mockRejectedValue(enoentError);

await expect(validatePath(newFilePath))
.rejects.toThrow('Parent directory does not exist');
});
Expand Down Expand Up @@ -339,6 +372,29 @@ describe('Lib Functions', () => {
});
});

describe('moveFile', () => {
it('moves the file when the destination does not exist', async () => {
const enoent = Object.assign(new Error('not found'), { code: 'ENOENT' });
mockFs.lstat.mockRejectedValueOnce(enoent);
mockFs.rename.mockResolvedValueOnce(undefined);

await moveFile('/test/source.txt', '/test/dest.txt');

expect(mockFs.rename).toHaveBeenCalledWith('/test/source.txt', '/test/dest.txt');
});

it('fails without overwriting when the destination already exists', async () => {
// lstat resolving means the destination is occupied.
mockFs.lstat.mockResolvedValueOnce({} as any);

await expect(moveFile('/test/source.txt', '/test/dest.txt')).rejects.toThrow(
'Destination already exists'
);

expect(mockFs.rename).not.toHaveBeenCalled();
});
});

});

describe('Search & Filtering Functions', () => {
Expand Down Expand Up @@ -698,6 +754,23 @@ describe('Lib Functions', () => {
expect(mockFileHandle.close).toHaveBeenCalled();
});

it('preserves UTF-8 characters split across chunk boundaries', async () => {
const content = Buffer.concat([
Buffer.from('discard\n'),
Buffer.from('界'),
Buffer.alloc(1017, 'a'),
Buffer.from('\nlast'),
]);
const mockFileHandle = createMockFileHandle(content);

mockFs.stat.mockResolvedValue({ size: content.length } as any);
mockFs.open.mockResolvedValue(mockFileHandle);

const result = await tailFile('/test/file.txt', 2);

expect(result).toBe(`界${'a'.repeat(1017)}\nlast`);
});

it('handles read errors gracefully', async () => {
mockFs.stat.mockResolvedValue({ size: 100 } as any);

Expand Down Expand Up @@ -754,6 +827,20 @@ describe('Lib Functions', () => {
expect(mockFileHandle.close).toHaveBeenCalled();
});

it('preserves UTF-8 characters split across chunk boundaries', async () => {
const content = Buffer.concat([
Buffer.alloc(1023, 'a'),
Buffer.from('界\nsecond'),
]);
const mockFileHandle = createMockFileHandle(content);

mockFs.open.mockResolvedValue(mockFileHandle);

const result = await headFile('/test/file.txt', 1);

expect(result).toBe(`${'a'.repeat(1023)}界`);
});

it('handles files with leftover content', async () => {
const mockFileHandle = {
read: vi.fn(),
Expand Down
35 changes: 35 additions & 0 deletions src/filesystem/__tests__/nested-parents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setAllowedDirectories, validatePath } from '../lib.js';

// Regression coverage for #4629: validatePath must accept a path whose
// ancestors are missing several levels deep, so create_directory can mkdir -p.
describe('validatePath with multiple missing ancestors', () => {
let allowedDir: string;
let outsideDir: string;

beforeEach(async () => {
allowedDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-nested-allowed-')));
outsideDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-nested-outside-')));
setAllowedDirectories([allowedDir]);
});

afterEach(async () => {
setAllowedDirectories([]);
await fs.rm(allowedDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
});

it('returns the full path when several ancestors do not exist', async () => {
const requested = path.join(allowedDir, 'a', 'b', 'c');
await expect(validatePath(requested)).resolves.toBe(requested);
});

it('rejects when the nearest existing ancestor is a symlink out of the allowed tree', async () => {
await fs.symlink(outsideDir, path.join(allowedDir, 'link'), 'junction');
await expect(validatePath(path.join(allowedDir, 'link', 'a', 'b')))
.rejects.toThrow('Access denied');
});
});
49 changes: 49 additions & 0 deletions src/filesystem/__tests__/unicode-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setAllowedDirectories, validatePath } from '../lib.js';

describe('Unicode-equivalent filesystem paths', () => {
let testDirectory: string;

beforeEach(async () => {
testDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-unicode-paths-'));
setAllowedDirectories([testDirectory]);
});

afterEach(async () => {
setAllowedDirectories([]);
await fs.rm(testDirectory, { recursive: true, force: true });
});

it('resolves an existing decomposed path from a composed request', async () => {
const onDiskDirectory = 'de\u0301marche';
const onDiskFile = 're\u0301sume\u0301.txt';
await fs.mkdir(path.join(testDirectory, onDiskDirectory));
await fs.writeFile(path.join(testDirectory, onDiskDirectory, onDiskFile), 'content');

const resolved = await validatePath(path.join(testDirectory, 'd\u00e9marche', 'r\u00e9sum\u00e9.txt'));

expect(resolved).toBe(await fs.realpath(path.join(testDirectory, onDiskDirectory, onDiskFile)));
});

it('preserves a new basename after resolving a Unicode-equivalent parent', async () => {
const onDiskDirectory = 'de\u0301marche';
await fs.mkdir(path.join(testDirectory, onDiskDirectory));

const resolved = await validatePath(path.join(testDirectory, 'd\u00e9marche', 'new.txt'));

expect(resolved).toBe(path.join(await fs.realpath(path.join(testDirectory, onDiskDirectory)), 'new.txt'));
});

it('rejects ambiguous canonically equivalent entries', async () => {
const composed = 'caf\u00e9';
const decomposed = 'cafe\u0301';
await fs.mkdir(path.join(testDirectory, composed));
await fs.mkdir(path.join(testDirectory, decomposed));

await expect(validatePath(path.join(testDirectory, 'cafe\u0341', 'file.txt')))
.rejects.toThrow('Ambiguous Unicode path component');
});
});
3 changes: 2 additions & 1 deletion src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getFileStats,
readFileContent,
writeFileContent,
moveFile,
searchFilesWithValidation,
applyFileEdits,
tailFile,
Expand Down Expand Up @@ -631,7 +632,7 @@ server.registerTool(
async (args: z.infer<typeof MoveFileArgsSchema>) => {
const validSourcePath = await validatePath(args.source);
const validDestPath = await validatePath(args.destination);
await fs.rename(validSourcePath, validDestPath);
await moveFile(validSourcePath, validDestPath);
const text = `Successfully moved ${args.source} to ${args.destination}`;
const contentBlock = { type: "text" as const, text };
return {
Expand Down
Loading