diff --git a/ccw/tests/path-validator.test.ts b/ccw/tests/path-validator.test.ts new file mode 100644 index 00000000..1fb16f42 --- /dev/null +++ b/ccw/tests/path-validator.test.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for path-validator utility module. + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Uses a stubbed `fs/promises.realpath` to simulate symlinks and ENOENT cases. + */ + +import { after, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const fsp = require('node:fs/promises') as typeof import('node:fs/promises'); + +const ORIGINAL_ENV = { ...process.env }; + +type RealpathPlan = Map; + +const realpathCalls: string[] = []; +const realpathPlan: RealpathPlan = new Map(); + +function enoent(message: string): Error & { code: string } { + const err = new Error(message) as Error & { code: string }; + err.code = 'ENOENT'; + return err; +} + +const originalRealpath = fsp.realpath; +fsp.realpath = (async (p: string) => { + realpathCalls.push(p); + const planned = realpathPlan.get(p); + if (!planned) { + throw enoent(`ENOENT: no such file or directory, realpath '${p}'`); + } + if (planned.type === 'throw') throw planned.error; + return planned.value; +}) as any; + +const pathValidatorUrl = new URL('../dist/utils/path-validator.js', import.meta.url).href; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mod: any; + +beforeEach(() => { + realpathCalls.length = 0; + realpathPlan.clear(); + + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + process.env[key] = value; + } +}); + +describe('path-validator utility module', async () => { + mod = await import(pathValidatorUrl); + + it('getProjectRoot uses CCW_PROJECT_ROOT or falls back to cwd', () => { + delete process.env.CCW_PROJECT_ROOT; + assert.equal(mod.getProjectRoot(), process.cwd()); + + process.env.CCW_PROJECT_ROOT = 'C:\\root'; + assert.equal(mod.getProjectRoot(), 'C:\\root'); + }); + + it('getAllowedDirectories parses CCW_ALLOWED_DIRS or falls back to project root', () => { + process.env.CCW_PROJECT_ROOT = 'C:\\root'; + delete process.env.CCW_ALLOWED_DIRS; + assert.deepEqual(mod.getAllowedDirectories(), ['C:\\root']); + + process.env.CCW_ALLOWED_DIRS = 'C:\\a, C:\\b ,,'; + assert.deepEqual(mod.getAllowedDirectories(), ['C:\\a', 'C:\\b']); + }); + + it('normalizePath and isPathWithinAllowedDirectories are cross-platform friendly', () => { + assert.equal(mod.normalizePath('C:\\a\\b'), 'C:/a/b'); + + assert.equal(mod.isPathWithinAllowedDirectories('C:/allowed', ['C:/allowed']), true); + assert.equal(mod.isPathWithinAllowedDirectories('C:/allowed/sub', ['C:/allowed']), true); + assert.equal(mod.isPathWithinAllowedDirectories('C:/allowedness/sub', ['C:/allowed']), false); + }); + + it('validatePath returns normalized real path for allowed targets', async () => { + process.env.CCW_PROJECT_ROOT = 'C:\\root'; + const absolute = path.resolve('C:\\root', 'sub', 'file.txt'); + realpathPlan.set(absolute, { type: 'return', value: absolute }); + + const result = await mod.validatePath(path.join('sub', 'file.txt'), { + allowedDirectories: ['C:\\root'], + }); + assert.equal(result, 'C:/root/sub/file.txt'); + assert.deepEqual(realpathCalls, [absolute]); + }); + + it('validatePath rejects paths outside allowed directories', async () => { + await assert.rejects( + mod.validatePath('C:\\secret\\file.txt', { allowedDirectories: ['C:\\allowed'] }), + (err: any) => err instanceof Error && err.message.includes('Access denied: path'), + ); + }); + + it('validatePath re-checks symlink target after realpath', async () => { + const link = 'C:\\allowed\\link.txt'; + realpathPlan.set(link, { type: 'return', value: 'C:\\secret\\target.txt' }); + + await assert.rejects( + mod.validatePath(link, { allowedDirectories: ['C:\\allowed'] }), + (err: any) => err instanceof Error && err.message.includes('symlink target'), + ); + }); + + it('validatePath handles ENOENT with mustExist and validates parent directory', async () => { + const missing = 'C:\\allowed\\missing.txt'; + realpathPlan.set(missing, { type: 'throw', error: enoent('missing') }); + + await assert.rejects( + mod.validatePath(missing, { allowedDirectories: ['C:\\allowed'], mustExist: true }), + (err: any) => err instanceof Error && err.message.includes('File not found'), + ); + + const newFile = 'C:\\allowed\\new\\file.txt'; + const parent = path.resolve(newFile, '..'); + realpathPlan.set(newFile, { type: 'throw', error: enoent('missing') }); + realpathPlan.set(parent, { type: 'return', value: parent }); + + const ok = await mod.validatePath(newFile, { allowedDirectories: ['C:\\allowed'] }); + assert.equal(ok, newFile); + + const newFileNoParent = 'C:\\allowed\\no-parent\\file.txt'; + const noParentDir = path.resolve(newFileNoParent, '..'); + realpathPlan.set(newFileNoParent, { type: 'throw', error: enoent('missing') }); + realpathPlan.set(noParentDir, { type: 'throw', error: enoent('parent missing') }); + + const okNoParent = await mod.validatePath(newFileNoParent, { allowedDirectories: ['C:\\allowed'] }); + assert.equal(okNoParent, newFileNoParent); + }); + + it('resolveProjectPath resolves under project root', () => { + process.env.CCW_PROJECT_ROOT = 'C:\\root'; + assert.equal(mod.resolveProjectPath('a', 'b'), path.resolve('C:\\root', 'a', 'b')); + }); +}); + +after(() => { + fsp.realpath = originalRealpath; + + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + process.env[key] = value; + } +}); +