Skip to content

Commit 3a19166

Browse files
committed
feat: add test for validate path
1 parent c9326fe commit 3a19166

File tree

4 files changed

+142
-9
lines changed

4 files changed

+142
-9
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Validate Path Tests
2+
3+
on:
4+
push:
5+
paths-ignore:
6+
- '**.md'
7+
- '**.txt'
8+
- '.github/**'
9+
pull_request:
10+
paths-ignore:
11+
- '**.md'
12+
- '**.txt'
13+
- '.github/**'
14+
15+
jobs:
16+
test:
17+
strategy:
18+
matrix:
19+
os: [ubuntu-latest, macos-latest, windows-latest]
20+
runs-on: ${{ matrix.os }}
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- name: Setup Node.js
25+
uses: actions/setup-node@v4
26+
with:
27+
node-version: '20'
28+
cache: 'npm'
29+
30+
- name: Install dependencies
31+
run: npm ci
32+
33+
- name: Run validate path tests
34+
run: npm run test:ci

__tests__/validatePath.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {validatePath} from '../utils/fileUtils';
2+
import fs from 'fs/promises';
3+
import path from 'path';
4+
import os from 'os';
5+
6+
describe('validatePath', () => {
7+
const testDir = path.join(process.cwd(), 'sample-validate-path');
8+
const allowedDirectories = [testDir];
9+
10+
beforeAll(async () => {
11+
// Create test directory and ensure it's clean
12+
await fs.rm(testDir, {recursive: true, force: true});
13+
await fs.mkdir(testDir, {recursive: true});
14+
});
15+
16+
afterAll(async () => {
17+
// Clean up test directory
18+
await fs.rm(testDir, {recursive: true, force: true});
19+
});
20+
21+
beforeEach(async () => {
22+
// Clean up any leftover symlinks before each test
23+
try {
24+
await fs.unlink(path.join(testDir, 'symlink.txt'));
25+
} catch (error) {
26+
// Ignore errors if file doesn't exist
27+
}
28+
});
29+
30+
test('should validate path within allowed directories', async () => {
31+
const testFilePath = path.join(testDir, 'test.txt');
32+
const validatedPath = await validatePath(testFilePath, allowedDirectories);
33+
expect(validatedPath).toBe(path.resolve(testFilePath));
34+
});
35+
36+
test('should expand home directory path', async () => {
37+
const homePath = '~/test.txt';
38+
const expandedPath = path.join(os.homedir(), 'test.txt');
39+
const validatedPath = await validatePath(homePath, [os.homedir()]);
40+
expect(validatedPath).toBe(path.resolve(expandedPath));
41+
});
42+
43+
test('should throw error for path outside allowed directories', async () => {
44+
const outsidePath = path.join(process.cwd(), 'outside.txt');
45+
await expect(validatePath(outsidePath, allowedDirectories)).rejects.toThrow(
46+
'Access denied - path outside allowed directories',
47+
);
48+
});
49+
50+
test('should handle symlinks within allowed directories pointing to allowed directories', async () => {
51+
const targetPath = path.join(testDir, 'target.txt');
52+
const symlinkPath = path.join(testDir, 'symlink.txt');
53+
54+
// Create target file
55+
await fs.writeFile(targetPath, 'test content');
56+
await fs.symlink(targetPath, symlinkPath);
57+
58+
const validatedPath = await validatePath(symlinkPath, allowedDirectories);
59+
expect(validatedPath).toBe(path.resolve(targetPath));
60+
61+
// Clean up
62+
await fs.unlink(symlinkPath);
63+
await fs.unlink(targetPath);
64+
});
65+
66+
test('should allow symlinks within allowed directories pointing outside', async () => {
67+
const outsidePath = path.join(process.cwd(), 'outside.txt');
68+
const symlinkPath = path.join(testDir, 'symlink.txt');
69+
70+
// Create target file outside allowed directories
71+
await fs.writeFile(outsidePath, 'test content');
72+
await fs.symlink(outsidePath, symlinkPath);
73+
74+
const validatedPath = await validatePath(symlinkPath, allowedDirectories);
75+
expect(validatedPath).toBe(path.resolve(outsidePath));
76+
77+
// Clean up
78+
await fs.unlink(symlinkPath);
79+
await fs.unlink(outsidePath);
80+
});
81+
82+
test('should handle non-existent files with valid parent directory', async () => {
83+
const newDir = path.join(testDir, 'new');
84+
await fs.mkdir(newDir, {recursive: true});
85+
const newFilePath = path.join(newDir, 'file.txt');
86+
const validatedPath = await validatePath(newFilePath, allowedDirectories);
87+
expect(validatedPath).toBe(path.resolve(newFilePath));
88+
});
89+
90+
test('should throw error for non-existent parent directory', async () => {
91+
const invalidPath = path.join(testDir, 'nonexistent', 'file.txt');
92+
await expect(validatePath(invalidPath, allowedDirectories)).rejects.toThrow(
93+
'Parent directory does not exist',
94+
);
95+
});
96+
});

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ai-file-edit",
3-
"version": "1.0.7",
3+
"version": "1.0.8",
44
"main": "dist/index.js",
55
"types": "dist/index.d.ts",
66
"type": "module",
@@ -23,6 +23,8 @@
2323
"test:claude": "npm run build && jest --verbose file-edit-claude.test.ts",
2424
"test:claude-multiple": "npm run build && jest --verbose file-edit-claude-multiple.test.ts",
2525
"test:openai-multiple": "npm run build && jest --verbose file-edit-openai-multiple.test.ts",
26+
"test:validate-path": "npm run build && jest --verbose validatePath.test.ts",
27+
"test:ci": "npm run build && jest --ci --coverage --verbose validatePath.test.ts",
2628
"build": "tsup index.ts --format esm,cjs --dts --clean",
2729
"prepublish": "npm run build",
2830
"prepare": "npm run build",

utils/fileUtils.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export async function validatePath(
2727

2828
const normalizedRequested = normalizePath(absolute);
2929

30-
// Check if path is within allowed directories
30+
// First check if the requested path is within allowed directories
3131
const isAllowed = allowedDirectories.some(dir => normalizedRequested.startsWith(dir));
3232
if (!isAllowed) {
3333
throw new Error(
@@ -37,15 +37,16 @@ export async function validatePath(
3737
);
3838
}
3939

40-
// Handle symlinks by checking their real path
40+
// Check if the path exists
4141
try {
42-
const realPath = await fs.realpath(absolute);
43-
const normalizedReal = normalizePath(realPath);
44-
const isRealPathAllowed = allowedDirectories.some(dir => normalizedReal.startsWith(dir));
45-
if (!isRealPathAllowed) {
46-
throw new Error('Access denied - symlink target outside allowed directories');
42+
const stats = await fs.lstat(absolute);
43+
if (stats.isSymbolicLink()) {
44+
// For symlinks, we trust them if they are within allowed directories
45+
// Get the real path for returning, but don't validate it
46+
const realPath = await fs.realpath(absolute);
47+
return realPath;
4748
}
48-
return realPath;
49+
return absolute;
4950
} catch (error) {
5051
// For new files that don't exist yet, verify parent directory
5152
const parentDir = path.dirname(absolute);

0 commit comments

Comments
 (0)