Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions packages/vscode-ide-companion/src/services/acpFileHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,5 +127,153 @@ describe('AcpFileHandler', () => {
'utf-8',
);
});

it('trims whitespace from path', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);

await handler.handleWriteTextFile({
path: ' /test/dir/file.txt ',
content: 'hello',
sessionId: 'sid',
});

expect(fs.writeFile).toHaveBeenCalledWith(
'/test/dir/file.txt',
'hello',
'utf-8',
);
});

it('rejects empty path', async () => {
await expect(
handler.handleWriteTextFile({
path: '',
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow('Invalid path: path must be a non-empty string');
});

it('rejects whitespace-only path', async () => {
await expect(
handler.handleWriteTextFile({
path: ' ',
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow(
'Invalid path: path cannot be empty or whitespace-only',
);
});

it('rejects non-string path (number)', async () => {
await expect(
handler.handleWriteTextFile({
path: 123 as unknown as string,
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow('Invalid path: path must be a non-empty string');
});

it('rejects null path', async () => {
await expect(
handler.handleWriteTextFile({
path: null as unknown as string,
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow('Invalid path: path must be a non-empty string');
});

it('rejects undefined path', async () => {
await expect(
handler.handleWriteTextFile({
path: undefined as unknown as string,
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow('Invalid path: path must be a non-empty string');
});

it('rejects path with null byte', async () => {
await expect(
handler.handleWriteTextFile({
path: '/test/file\0.txt',
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow('Invalid path: path contains null byte character');
});

it('handles EINVAL error with helpful message', async () => {
vi.mocked(fs.mkdir).mockRejectedValue(
Object.assign(new Error('invalid argument'), { code: 'EINVAL' }),
);

await expect(
handler.handleWriteTextFile({
path: '/invalid:path/file.txt',
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow('Invalid path');
});

it('handles EACCES error with helpful message', async () => {
vi.mocked(fs.mkdir).mockRejectedValue(
Object.assign(new Error('permission denied'), { code: 'EACCES' }),
);

await expect(
handler.handleWriteTextFile({
path: '/root/protected/file.txt',
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow('Permission denied');
});

it('handles ENOSPC error with helpful message', async () => {
vi.mocked(fs.mkdir).mockRejectedValue(
Object.assign(new Error('no space left'), { code: 'ENOSPC' }),
);

await expect(
handler.handleWriteTextFile({
path: '/test/file.txt',
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow('No space left on device');
});

it('handles EISDIR error with helpful message', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockRejectedValue(
Object.assign(new Error('is a directory'), { code: 'EISDIR' }),
);

await expect(
handler.handleWriteTextFile({
path: '/test/directory',
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow('Cannot write to directory');
});

it('handles generic errors', async () => {
vi.mocked(fs.mkdir).mockRejectedValue(new Error('something went wrong'));

await expect(
handler.handleWriteTextFile({
path: '/test/file.txt',
content: 'hello',
sessionId: 'sid',
}),
).rejects.toThrow("Failed to write file '/test/file.txt'");
});
});
});
64 changes: 59 additions & 5 deletions packages/vscode-ide-companion/src/services/acpFileHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,22 +97,76 @@ export class AcpFileHandler {
);
console.log(`[ACP] Content size: ${params.content.length} bytes`);

// Validate path parameter
if (!params.path || typeof params.path !== 'string') {
const error = new Error(
`Invalid path: path must be a non-empty string, received ${String(params.path)}`,
);
console.error(`[ACP] Invalid path parameter:`, params.path);
throw error;
}

// Trim and validate the path
const trimmedPath = params.path.trim();
if (!trimmedPath) {
const error = new Error(
'Invalid path: path cannot be empty or whitespace-only',
);
console.error(`[ACP] Empty path provided`);
throw error;
}

// Check for null bytes which can cause security issues
if (trimmedPath.includes('\0')) {
const error = new Error(
'Invalid path: path contains null byte character',
);
console.error(`[ACP] Path contains null byte:`, trimmedPath);
throw error;
}

try {
// Ensure directory exists
const dirName = path.dirname(params.path);
const dirName = path.dirname(trimmedPath);
console.log(`[ACP] Ensuring directory exists: ${dirName}`);
await fs.mkdir(dirName, { recursive: true });

// Write file
await fs.writeFile(params.path, params.content, 'utf-8');
await fs.writeFile(trimmedPath, params.content, 'utf-8');

console.log(`[ACP] Successfully wrote file: ${params.path}`);
console.log(`[ACP] Successfully wrote file: ${trimmedPath}`);
return null;
} catch (error) {
const errorMsg = getErrorMessage(error);
console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg);
console.error(`[ACP] Failed to write file ${trimmedPath}:`, errorMsg);

// Provide more specific error messages based on error code
const nodeError = error as NodeJS.ErrnoException;
if (nodeError?.code === 'EINVAL') {
throw new Error(
`Invalid path '${trimmedPath}': ${errorMsg}. Ensure the path format is valid for your operating system.`,
{ cause: error },
);
} else if (nodeError?.code === 'EACCES') {
throw new Error(
`Permission denied writing to '${trimmedPath}': ${errorMsg}`,
{ cause: error },
);
} else if (nodeError?.code === 'ENOSPC') {
throw new Error(
`No space left on device while writing to '${trimmedPath}': ${errorMsg}`,
{ cause: error },
);
} else if (nodeError?.code === 'EISDIR') {
throw new Error(
`Cannot write to directory '${trimmedPath}': ${errorMsg}. Expected a file path.`,
{ cause: error },
);
}

throw new Error(`Failed to write file '${params.path}': ${errorMsg}`);
throw new Error(`Failed to write file '${trimmedPath}': ${errorMsg}`, {
cause: error,
});
}
}
}
Loading