diff --git a/packages/context/file-reference-local/tests/search.spec.ts b/packages/context/file-reference-local/tests/search.spec.ts index 7b48148bf3..1b50e812b4 100644 --- a/packages/context/file-reference-local/tests/search.spec.ts +++ b/packages/context/file-reference-local/tests/search.spec.ts @@ -9,6 +9,24 @@ import { WorkspaceFileSearch, } from '../src/search.ts' +const fsControl = vi.hoisted(() => ({ + /** Absolute path whose `readdir` rejects; the injectable stand-in for chmod 0. */ + denyReaddir: undefined as string | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readdir: (async (path: unknown, ...rest: never[]) => { + if (fsControl.denyReaddir !== undefined && String(path) === fsControl.denyReaddir) { + throw Object.assign(new Error('EACCES: injected unreadable directory'), { code: 'EACCES' }) + } + return (actual.readdir as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.readdir, + } +}) + const searches: WorkspaceFileSearch[] = [] const roots: string[] = [] /** Permission-stripped directories; restored before cleanup can remove them. */ @@ -199,7 +217,13 @@ describe('WorkspaceFileSearch', () => { }) }) - it('lets an unreadable subtree cost only its own candidates', async () => { + // chmod 0 can only deny directory reads on POSIX to a non-root owner: + // Windows exposes no directory permission bits for readdir, and root + // bypasses them. Where the fixture stays readable the sealed candidate is + // indexed, so the unreadable-branch behavior is pinned on POSIX non-root. + it.runIf( + process.getuid !== undefined && process.getuid() !== 0, + )('lets an unreadable subtree cost only its own candidates', async () => { const root = await workspace() const locked = join(root, 'locked') await mkdir(locked, { recursive: true }) @@ -216,6 +240,29 @@ describe('WorkspaceFileSearch', () => { expect(await files.list('locked', signal)).toEqual([{ path: 'locked', kind: 'directory' }]) }) + // The chmod-0 fixture above cannot be built on Windows (no directory + // permission bits) or as root (bits are bypassed). An injected readdir + // failure keeps the unreadable-branch behavior covered on every platform. + it('lets an injected readdir failure cost only its own candidates', async () => { + const root = await workspace() + const locked = join(root, 'locked') + await mkdir(locked, { recursive: true }) + await writeFile(join(locked, 'sealed.ts'), 'sealed') + fsControl.denyReaddir = locked + try { + const files = search(root) + const signal = new AbortController().signal + + // The branch itself yields nothing, and the rest of the tree still does. + expect(await files.list('sealed', signal)).toEqual([]) + expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }]) + // The directory is still offered: only reading through it fails. + expect(await files.list('locked', signal)).toEqual([{ path: 'locked', kind: 'directory' }]) + } finally { + fsControl.denyReaddir = undefined + } + }) + it('enforces the entry cap', async () => { const root = await workspace() const capped = search(root, { maxEntries: 2 })