The scope check at routes/projects.ts:56 short-circuited when real === whitelistReal, allowing the whitelist directory itself to resolve as a valid project root. Dropped the `real !== whitelistReal` half of the && so the predicate becomes the strict prefix check. Flipped the unit test from a "BEHAVIOR GAP" assertion (documenting the bug) to a strict-rejection assertion. 23/23 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
93 lines
3.7 KiB
TypeScript
93 lines
3.7 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { mkdtemp, mkdir, rm, realpath, symlink, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { resolveProjectPath } from '../projects.js';
|
|
|
|
describe('resolveProjectPath', () => {
|
|
let scratch: string;
|
|
let whitelist: string;
|
|
let outside: string;
|
|
|
|
beforeAll(async () => {
|
|
// mkdtemp returns a real path on most platforms, but symlink-resolve it
|
|
// anyway to defeat /tmp -> /private/tmp style indirection on macOS, and
|
|
// keep this stable for the comparisons below.
|
|
scratch = await realpath(await mkdtemp(join(tmpdir(), 'boocode-projects-test-')));
|
|
whitelist = join(scratch, 'wl');
|
|
outside = join(scratch, 'other');
|
|
await mkdir(whitelist, { recursive: true });
|
|
await mkdir(outside, { recursive: true });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (scratch) {
|
|
await rm(scratch, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns real path and basename for a valid subdirectory under the whitelist', async () => {
|
|
const projectDir = join(whitelist, 'my-project');
|
|
await mkdir(projectDir);
|
|
const result = await resolveProjectPath(projectDir, whitelist);
|
|
expect('error' in result).toBe(false);
|
|
if ('error' in result) return; // narrow
|
|
expect(result.real).toBe(projectDir);
|
|
expect(result.name).toBe('my-project');
|
|
});
|
|
|
|
it('rejects a path outside the whitelist', async () => {
|
|
const projectDir = join(outside, 'foo');
|
|
await mkdir(projectDir);
|
|
const result = await resolveProjectPath(projectDir, whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('path must be under');
|
|
});
|
|
|
|
it('rejects relative paths', async () => {
|
|
const result = await resolveProjectPath('relative/path', whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('absolute');
|
|
});
|
|
|
|
it('rejects nonexistent paths', async () => {
|
|
const result = await resolveProjectPath('/nonexistent/foo-boocode-test', whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('does not exist');
|
|
});
|
|
|
|
it('rejects symlink escapes (realpath resolution catches traversal)', async () => {
|
|
// Create a symlink INSIDE the whitelist that points OUTSIDE. realpath
|
|
// should resolve through it and the resulting real path should fail the
|
|
// whitelist scope check.
|
|
const escapeLink = join(whitelist, 'escape-link');
|
|
await symlink(outside, escapeLink);
|
|
const result = await resolveProjectPath(escapeLink, whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('path must be under');
|
|
});
|
|
|
|
it('rejects the whitelist directory itself as a project root', async () => {
|
|
// A project's parent can't be the project. The scope check must require
|
|
// the candidate path to be strictly below the whitelist (whitelist + sep
|
|
// prefix), not just equal to it.
|
|
const result = await resolveProjectPath(whitelist, whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('path must be under');
|
|
});
|
|
|
|
it('rejects non-directory targets (file under whitelist)', async () => {
|
|
const filePath = join(whitelist, 'a-file.txt');
|
|
await writeFile(filePath, 'content', 'utf8');
|
|
const result = await resolveProjectPath(filePath, whitelist);
|
|
expect('error' in result).toBe(true);
|
|
if (!('error' in result)) return;
|
|
expect(result.error.toLowerCase()).toContain('not a directory');
|
|
});
|
|
});
|