import { describe, it, expect, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js'; const created: string[] = []; function freshWorktree(): string { const wt = mkdtempSync(join(tmpdir(), 'acp-wt-')); created.push(wt); return wt; } afterEach(() => { for (const d of created.splice(0)) { try { rmSync(d, { recursive: true, force: true }); rmSync(`${d}-evil`, { recursive: true, force: true }); } catch { /* ignore */ } } }); describe('acp-client-fs worktree scoping', () => { it('writes then reads a file inside the worktree', async () => { const wt = freshWorktree(); await writeWorktreeTextFile(wt, 'sub/dir/note.txt', 'hello'); expect(await readWorktreeTextFile(wt, 'sub/dir/note.txt')).toBe('hello'); }); it('rejects ../ traversal on read', async () => { const wt = freshWorktree(); await expect(readWorktreeTextFile(wt, '../../etc/passwd')).rejects.toThrow(/escapes worktree/); }); it('rejects ../ traversal on write', async () => { const wt = freshWorktree(); await expect(writeWorktreeTextFile(wt, '../escape.txt', 'x')).rejects.toThrow(/escapes worktree/); }); it('rejects a sibling-prefix path (the unbounded-startsWith bug)', async () => { const wt = freshWorktree(); // Absolute path that shares the worktree as a STRING prefix but is a sibling // dir: `-evil/...`. A bare `startsWith()` wrongly admits it. await expect(readWorktreeTextFile(wt, `${wt}-evil/secret.txt`)).rejects.toThrow(/escapes worktree/); await expect(writeWorktreeTextFile(wt, `${wt}-evil/secret.txt`, 'x')).rejects.toThrow( /escapes worktree/, ); }); });