From f79af8002fbe67a72249afc30913e2c80dc056eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 6 Apr 2026 14:34:08 +0200 Subject: [PATCH 1/6] fix(init): use opendir for listDir and validate symlinks during traversal Replace the recursive readdirSync walk with fs.promises.opendir({ recursive, bufferSize: 1024 }) for cleaner iteration. Add symlink validation via safePath on each entry so symlinks pointing outside the project directory are excluded from listings. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/init/local-ops.ts | 69 +++++++++++++++++------------ src/lib/init/wizard-runner.ts | 2 +- test/lib/init/local-ops.test.ts | 32 ++++++++++--- test/lib/init/wizard-runner.test.ts | 7 +-- 4 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 5ad09220f..c47cd138a 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -284,8 +284,10 @@ function safePath(cwd: string, relative: string): string { * Pre-compute directory listing before the first API call. * Uses the same parameters the server's discover-context step would request. */ -export function precomputeDirListing(directory: string): DirEntry[] { - const result = listDir({ +export async function precomputeDirListing( + directory: string +): Promise { + const result = await listDir({ type: "local-op", operation: "list-dir", cwd: directory, @@ -344,7 +346,10 @@ export async function handleLocalOp( } } -function listDir(payload: ListDirPayload): LocalOpResult { +/** Directory names that are listed at their level but never recursed into. */ +const SKIP_DIRS = new Set(["node_modules"]); + +async function listDir(payload: ListDirPayload): Promise { const { cwd, params } = payload; const targetPath = safePath(cwd, params.path); const maxDepth = params.maxDepth ?? 3; @@ -353,40 +358,48 @@ function listDir(payload: ListDirPayload): LocalOpResult { const entries: DirEntry[] = []; - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: walking the directory tree is a complex operation - function walk(dir: string, depth: number): void { - if (entries.length >= maxEntries || depth > maxDepth) { - return; - } + try { + const dir = await fs.promises.opendir(targetPath, { + recursive, + bufferSize: 1024, + }); - let dirEntries: fs.Dirent[]; - try { - dirEntries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } + for await (const entry of dir) { + if (entries.length >= maxEntries) break; + + const relFromTarget = path.relative(targetPath, entry.parentPath); + const depth = + relFromTarget === "" ? 0 : relFromTarget.split(path.sep).length; - for (const entry of dirEntries) { - if (entries.length >= maxEntries) { - return; + if (depth > maxDepth) continue; + + // Skip entries nested inside hidden dirs or node_modules, + // but still list the skip-dirs themselves at their parent level. + if (relFromTarget !== "") { + const segments = relFromTarget.split(path.sep); + if (segments.some((s) => s.startsWith(".") || SKIP_DIRS.has(s))) { + continue; + } } - const relPath = path.relative(cwd, path.join(dir, entry.name)); - const type = entry.isDirectory() ? "directory" : "file"; - entries.push({ name: entry.name, path: relPath, type }); + const relPath = path.relative(cwd, path.join(entry.parentPath, entry.name)); - if ( - recursive && - entry.isDirectory() && - !entry.name.startsWith(".") && - entry.name !== "node_modules" - ) { - walk(path.join(dir, entry.name), depth + 1); + // If this entry is a symlink, verify it doesn't escape the project directory. + if (entry.isSymbolicLink()) { + try { + safePath(cwd, relPath); + } catch { + continue; + } } + + const type = entry.isDirectory() ? "directory" : "file"; + entries.push({ name: entry.name, path: relPath, type }); } + } catch { + // Directory doesn't exist or can't be read } - walk(targetPath, 0); return { ok: true, data: { entries } }; } diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index e8edf55c0..99b8087a4 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -623,7 +623,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { let run: Awaited>; let result: WorkflowRunResult; try { - const dirListing = precomputeDirListing(directory); + const dirListing = await precomputeDirListing(directory); spin.message("Connecting to wizard..."); run = await workflow.createRun(); result = assertWorkflowResult( diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index ebbc38dfa..a05836104 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -462,6 +462,26 @@ describe("handleLocalOp", () => { // Depth 2+ should not be reached expect(paths).not.toContain(join("a", "b", "c")); }); + + test("excludes symlinks that point outside project directory", async () => { + writeFileSync(join(testDir, "legit.ts"), "x"); + symlinkSync("/tmp", join(testDir, "escape-link")); + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: "." }, + }; + + const result = await handleLocalOp(payload, options); + const entries = (result.data as { entries: Array<{ name: string }> }) + .entries; + const names = entries.map((e) => e.name); + + expect(names).toContain("legit.ts"); + expect(names).not.toContain("escape-link"); + }); }); describe("read-files", () => { @@ -906,11 +926,11 @@ describe("precomputeDirListing", () => { rmSync(testDir, { recursive: true, force: true }); }); - test("returns DirEntry[] directly", () => { + test("returns DirEntry[] directly", async () => { writeFileSync(join(testDir, "app.ts"), "x"); mkdirSync(join(testDir, "src")); - const entries = precomputeDirListing(testDir); + const entries = await precomputeDirListing(testDir); expect(Array.isArray(entries)).toBe(true); expect(entries.length).toBeGreaterThanOrEqual(2); @@ -926,16 +946,16 @@ describe("precomputeDirListing", () => { expect(dir?.type).toBe("directory"); }); - test("returns empty array for non-existent directory", () => { - const entries = precomputeDirListing(join(testDir, "nope")); + test("returns empty array for non-existent directory", async () => { + const entries = await precomputeDirListing(join(testDir, "nope")); expect(entries).toEqual([]); }); - test("recursively lists nested entries", () => { + test("recursively lists nested entries", async () => { mkdirSync(join(testDir, "a")); writeFileSync(join(testDir, "a", "nested.ts"), "x"); - const entries = precomputeDirListing(testDir); + const entries = await precomputeDirListing(testDir); const paths = entries.map((e) => e.path); expect(paths).toContain(join("a", "nested.ts")); }); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 02f43d599..111afc1fc 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -193,9 +193,10 @@ beforeEach(() => { ok: true, data: { results: [] }, }); - precomputeDirListingSpy = spyOn(ops, "precomputeDirListing").mockReturnValue( - [] - ); + precomputeDirListingSpy = spyOn( + ops, + "precomputeDirListing" + ).mockResolvedValue([]); handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({ action: "continue", }); From a30448e1439089342f4eb0dc17dd8015a8a689e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 6 Apr 2026 14:37:21 +0200 Subject: [PATCH 2/6] fix(init): resolve biome lint and formatting errors in listDir Extract toDirEntry, isInsideSkippedDir, and isEscapingSymlink helpers to reduce cognitive complexity below threshold. Fix block statement and formatting violations. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/init/local-ops.ts | 91 ++++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 29 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index c47cd138a..add2d274f 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -349,6 +349,62 @@ export async function handleLocalOp( /** Directory names that are listed at their level but never recursed into. */ const SKIP_DIRS = new Set(["node_modules"]); +/** + * Check whether an entry is inside a hidden dir or node_modules. + * Top-level skip-dirs (relFromTarget === "") are still listed. + */ +function isInsideSkippedDir(relFromTarget: string): boolean { + if (relFromTarget === "") { + return false; + } + const segments = relFromTarget.split(path.sep); + return segments.some((s) => s.startsWith(".") || SKIP_DIRS.has(s)); +} + +/** Return true when a symlink resolves to a path outside `cwd`. */ +function isEscapingSymlink( + entry: fs.Dirent, + cwd: string, + relPath: string +): boolean { + if (!entry.isSymbolicLink()) { + return false; + } + try { + safePath(cwd, relPath); + return false; + } catch { + return true; + } +} + +/** Convert a Dirent to a DirEntry, or return null if it should be skipped. */ +function toDirEntry( + entry: fs.Dirent, + cwd: string, + targetPath: string, + maxDepth: number +): DirEntry | null { + const relFromTarget = path.relative(targetPath, entry.parentPath); + const depth = relFromTarget === "" ? 0 : relFromTarget.split(path.sep).length; + + if (depth > maxDepth) { + return null; + } + if (isInsideSkippedDir(relFromTarget)) { + return null; + } + + const relPath = path.relative(cwd, path.join(entry.parentPath, entry.name)); + + if (isEscapingSymlink(entry, cwd, relPath)) { + return null; + } + + const type = entry.isDirectory() ? "directory" : "file"; + return { name: entry.name, path: relPath, type }; +} + async function listDir(payload: ListDirPayload): Promise { const { cwd, params } = payload; const targetPath = safePath(cwd, params.path); @@ -364,37 +420,14 @@ async function listDir(payload: ListDirPayload): Promise { bufferSize: 1024, }); - for await (const entry of dir) { - if (entries.length >= maxEntries) break; - - const relFromTarget = path.relative(targetPath, entry.parentPath); - const depth = - relFromTarget === "" ? 0 : relFromTarget.split(path.sep).length; - - if (depth > maxDepth) continue; - - // Skip entries nested inside hidden dirs or node_modules, - // but still list the skip-dirs themselves at their parent level. - if (relFromTarget !== "") { - const segments = relFromTarget.split(path.sep); - if (segments.some((s) => s.startsWith(".") || SKIP_DIRS.has(s))) { - continue; - } + for await (const dirent of dir) { + if (entries.length >= maxEntries) { + break; } - - const relPath = path.relative(cwd, path.join(entry.parentPath, entry.name)); - - // If this entry is a symlink, verify it doesn't escape the project directory. - if (entry.isSymbolicLink()) { - try { - safePath(cwd, relPath); - } catch { - continue; - } + const parsed = toDirEntry(dirent, cwd, targetPath, maxDepth); + if (parsed) { + entries.push(parsed); } - - const type = entry.isDirectory() ? "directory" : "file"; - entries.push({ name: entry.name, path: relPath, type }); } } catch { // Directory doesn't exist or can't be read From ff011cd6fbf335d5ab2e26bc4fa155d1dc05f3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 6 Apr 2026 14:41:21 +0200 Subject: [PATCH 3/6] refactor(init): convert readFiles, fileExistsBatch, applyPatchset to async Use fs.promises throughout for non-blocking I/O. readFiles and fileExistsBatch now process paths in parallel via Promise.all. applyPatchset remains sequential since patches may depend on prior creates. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/init/local-ops.ts | 189 ++++++++++++++++++++++---------------- 1 file changed, 111 insertions(+), 78 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index add2d274f..67c079861 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -436,59 +436,82 @@ async function listDir(payload: ListDirPayload): Promise { return { ok: true, data: { entries } }; } -function readFiles(payload: ReadFilesPayload): LocalOpResult { - const { cwd, params } = payload; - const maxBytes = params.maxBytes ?? MAX_FILE_BYTES; - const files: Record = {}; - - for (const filePath of params.paths) { - try { - const absPath = safePath(cwd, filePath); - const stat = fs.statSync(absPath); - let content: string; - if (stat.size > maxBytes) { - // Read only up to maxBytes +async function readSingleFile( + cwd: string, + filePath: string, + maxBytes: number +): Promise { + try { + const absPath = safePath(cwd, filePath); + const stat = await fs.promises.stat(absPath); + let content: string; + if (stat.size > maxBytes) { + const fh = await fs.promises.open(absPath, "r"); + try { const buffer = Buffer.alloc(maxBytes); - const fd = fs.openSync(absPath, "r"); - try { - fs.readSync(fd, buffer, 0, maxBytes, 0); - } finally { - fs.closeSync(fd); - } + await fh.read(buffer, 0, maxBytes, 0); content = buffer.toString("utf-8"); - } else { - content = fs.readFileSync(absPath, "utf-8"); + } finally { + await fh.close(); } + } else { + content = await fs.promises.readFile(absPath, "utf-8"); + } - // Minify JSON files by stripping whitespace/formatting - if (filePath.endsWith(".json")) { - try { - content = JSON.stringify(JSON.parse(content)); - } catch { - // Not valid JSON (truncated, JSONC, etc.) — send as-is - } + // Minify JSON files by stripping whitespace/formatting + if (filePath.endsWith(".json")) { + try { + content = JSON.stringify(JSON.parse(content)); + } catch { + // Not valid JSON (truncated, JSONC, etc.) — send as-is } - - files[filePath] = content; - } catch { - files[filePath] = null; } + + return content; + } catch { + return null; + } +} + +async function readFiles(payload: ReadFilesPayload): Promise { + const { cwd, params } = payload; + const maxBytes = params.maxBytes ?? MAX_FILE_BYTES; + + const results = await Promise.all( + params.paths.map(async (filePath) => { + const content = await readSingleFile(cwd, filePath, maxBytes); + return [filePath, content] as const; + }) + ); + + const files: Record = {}; + for (const [filePath, content] of results) { + files[filePath] = content; } return { ok: true, data: { files } }; } -function fileExistsBatch(payload: FileExistsBatchPayload): LocalOpResult { +async function fileExistsBatch( + payload: FileExistsBatchPayload +): Promise { const { cwd, params } = payload; - const exists: Record = {}; - for (const filePath of params.paths) { - try { - const absPath = safePath(cwd, filePath); - exists[filePath] = fs.existsSync(absPath); - } catch { - exists[filePath] = false; - } + const results = await Promise.all( + params.paths.map(async (filePath) => { + try { + const absPath = safePath(cwd, filePath); + await fs.promises.access(absPath); + return [filePath, true] as const; + } catch { + return [filePath, false] as const; + } + }) + ); + + const exists: Record = {}; + for (const [filePath, found] of results) { + exists[filePath] = found; } return { ok: true, data: { exists } }; @@ -626,24 +649,56 @@ function applyPatchsetDryRun(payload: ApplyPatchsetPayload): LocalOpResult { * indentation style is detected and preserved. For `create` actions, a default * of 2-space indentation is used. */ -function resolvePatchContent( +async function resolvePatchContent( absPath: string, patch: ApplyPatchsetPayload["params"]["patches"][number] -): string { +): Promise { if (!patch.path.endsWith(".json")) { return patch.patch; } if (patch.action === "modify") { - const existing = fs.readFileSync(absPath, "utf-8"); + const existing = await fs.promises.readFile(absPath, "utf-8"); return prettyPrintJson(patch.patch, detectJsonIndent(existing)); } return prettyPrintJson(patch.patch, DEFAULT_JSON_INDENT); } -function applyPatchset( +type Patch = ApplyPatchsetPayload["params"]["patches"][number]; + +const VALID_PATCH_ACTIONS = new Set(["create", "modify", "delete"]); + +async function applySinglePatch(absPath: string, patch: Patch): Promise { + switch (patch.action) { + case "create": { + await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); + const content = await resolvePatchContent(absPath, patch); + await fs.promises.writeFile(absPath, content, "utf-8"); + break; + } + case "modify": { + const content = await resolvePatchContent(absPath, patch); + await fs.promises.writeFile(absPath, content, "utf-8"); + break; + } + case "delete": { + try { + await fs.promises.unlink(absPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } + break; + } + default: + break; + } +} + +async function applyPatchset( payload: ApplyPatchsetPayload, dryRun?: boolean -): LocalOpResult { +): Promise { if (dryRun) { return applyPatchsetDryRun(payload); } @@ -653,7 +708,7 @@ function applyPatchset( // Phase 1: Validate all paths and actions before writing anything for (const patch of params.patches) { safePath(cwd, patch.path); - if (!["create", "modify", "delete"].includes(patch.action)) { + if (!VALID_PATCH_ACTIONS.has(patch.action)) { return { ok: false, error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, @@ -661,48 +716,26 @@ function applyPatchset( } } - // Phase 2: Apply patches + // Phase 2: Apply patches (sequential — later patches may depend on earlier creates) const applied: Array<{ path: string; action: string }> = []; for (const patch of params.patches) { const absPath = safePath(cwd, patch.path); - switch (patch.action) { - case "create": { - const dir = path.dirname(absPath); - fs.mkdirSync(dir, { recursive: true }); - const content = resolvePatchContent(absPath, patch); - fs.writeFileSync(absPath, content, "utf-8"); - applied.push({ path: patch.path, action: "create" }); - break; - } - case "modify": { - if (!fs.existsSync(absPath)) { - return { - ok: false, - error: `Cannot modify "${patch.path}": file does not exist`, - data: { applied }, - }; - } - const content = resolvePatchContent(absPath, patch); - fs.writeFileSync(absPath, content, "utf-8"); - applied.push({ path: patch.path, action: "modify" }); - break; - } - case "delete": { - if (fs.existsSync(absPath)) { - fs.unlinkSync(absPath); - } - applied.push({ path: patch.path, action: "delete" }); - break; - } - default: + if (patch.action === "modify") { + try { + await fs.promises.access(absPath); + } catch { return { ok: false, - error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, + error: `Cannot modify "${patch.path}": file does not exist`, data: { applied }, }; + } } + + await applySinglePatch(absPath, patch); + applied.push({ path: patch.path, action: patch.action }); } return { ok: true, data: { applied } }; From fc901d5295a53a1946fe13a6be215e2b16246516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 6 Apr 2026 14:53:54 +0200 Subject: [PATCH 4/6] fix(init): use manual recursion in listDir to avoid symlink traversal opendir({ recursive: true }) follows symlinks into their targets before we can filter them, causing EACCES errors that abort the entire listing. Additionally, Bun's opendir defers ENOENT to iteration rather than throwing at open time. Switch to non-recursive opendir with manual walk, and add catch around iteration for robustness. Add test for nested symlinks in recursive mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/init/local-ops.ts | 127 +++++++++++++++++--------------- test/lib/init/local-ops.test.ts | 21 ++++++ 2 files changed, 87 insertions(+), 61 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 67c079861..b5183fa78 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -346,30 +346,11 @@ export async function handleLocalOp( } } -/** Directory names that are listed at their level but never recursed into. */ +/** Directory names that are listed but never recursed into. */ const SKIP_DIRS = new Set(["node_modules"]); -/** - * Check whether an entry is inside a hidden dir or node_modules. - * Top-level skip-dirs (relFromTarget === "") are still listed. - */ -function isInsideSkippedDir(relFromTarget: string): boolean { - if (relFromTarget === "") { - return false; - } - const segments = relFromTarget.split(path.sep); - return segments.some((s) => s.startsWith(".") || SKIP_DIRS.has(s)); -} - -/** Return true when a symlink resolves to a path outside `cwd`. */ -function isEscapingSymlink( - entry: fs.Dirent, - cwd: string, - relPath: string -): boolean { - if (!entry.isSymbolicLink()) { - return false; - } +/** Return true if a symlink escapes the project directory. */ +function isEscapingSymlink(cwd: string, relPath: string): boolean { try { safePath(cwd, relPath); return false; @@ -378,62 +359,86 @@ function isEscapingSymlink( } } -/** Convert a Dirent to a DirEntry, or return null if it should be skipped. */ -function toDirEntry( - entry: fs.Dirent, - cwd: string, - targetPath: string, - maxDepth: number -): DirEntry | null { - const relFromTarget = path.relative(targetPath, entry.parentPath); - const depth = relFromTarget === "" ? 0 : relFromTarget.split(path.sep).length; - - if (depth > maxDepth) { - return null; - } - if (isInsideSkippedDir(relFromTarget)) { - return null; +/** Whether a directory entry should be recursed into. */ +function shouldRecurse(entry: fs.Dirent): boolean { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + return false; } + return !(entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)); +} - const relPath = path.relative(cwd, path.join(entry.parentPath, entry.name)); +type WalkContext = { + cwd: string; + recursive: boolean; + maxDepth: number; + maxEntries: number; + entries: DirEntry[]; +}; - if (isEscapingSymlink(entry, cwd, relPath)) { - return null; +/** Process a single dirent during directory walking. */ +async function processDirEntry( + ctx: WalkContext, + dir: string, + entry: fs.Dirent, + depth: number +): Promise { + const relPath = path.relative(ctx.cwd, path.join(dir, entry.name)); + if (entry.isSymbolicLink() && isEscapingSymlink(ctx.cwd, relPath)) { + return; } const type = entry.isDirectory() ? "directory" : "file"; - return { name: entry.name, path: relPath, type }; -} + ctx.entries.push({ name: entry.name, path: relPath, type }); -async function listDir(payload: ListDirPayload): Promise { - const { cwd, params } = payload; - const targetPath = safePath(cwd, params.path); - const maxDepth = params.maxDepth ?? 3; - const maxEntries = params.maxEntries ?? 500; - const recursive = params.recursive ?? false; + if (ctx.recursive && shouldRecurse(entry)) { + await walkDir(ctx, path.join(dir, entry.name), depth + 1); + } +} - const entries: DirEntry[] = []; +async function walkDir( + ctx: WalkContext, + dir: string, + depth: number +): Promise { + if (ctx.entries.length >= ctx.maxEntries || depth > ctx.maxDepth) { + return; + } + let handle: fs.Dir; try { - const dir = await fs.promises.opendir(targetPath, { - recursive, - bufferSize: 1024, - }); + handle = await fs.promises.opendir(dir, { bufferSize: 1024 }); + } catch { + return; + } - for await (const dirent of dir) { - if (entries.length >= maxEntries) { + try { + for await (const entry of handle) { + if (ctx.entries.length >= ctx.maxEntries) { break; } - const parsed = toDirEntry(dirent, cwd, targetPath, maxDepth); - if (parsed) { - entries.push(parsed); - } + await processDirEntry(ctx, dir, entry, depth); } } catch { - // Directory doesn't exist or can't be read + // Directory unreadable (ENOENT, EACCES, etc.) — skip gracefully + } finally { + await handle.close(); } +} + +async function listDir(payload: ListDirPayload): Promise { + const { cwd, params } = payload; + const targetPath = safePath(cwd, params.path); + + const ctx: WalkContext = { + cwd, + recursive: params.recursive ?? false, + maxDepth: params.maxDepth ?? 3, + maxEntries: params.maxEntries ?? 500, + entries: [], + }; - return { ok: true, data: { entries } }; + await walkDir(ctx, targetPath, 0); + return { ok: true, data: { entries: ctx.entries } }; } async function readSingleFile( diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index a05836104..f80e6e5b1 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -482,6 +482,27 @@ describe("handleLocalOp", () => { expect(names).toContain("legit.ts"); expect(names).not.toContain("escape-link"); }); + + test("excludes nested symlinks that point outside project directory in recursive mode", async () => { + mkdirSync(join(testDir, "sub")); + writeFileSync(join(testDir, "sub", "legit.ts"), "x"); + symlinkSync("/tmp", join(testDir, "sub", "escape-link")); + + const payload: ListDirPayload = { + type: "local-op", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 3 }, + }; + + const result = await handleLocalOp(payload, options); + const entries = (result.data as { entries: Array<{ path: string }> }) + .entries; + const paths = entries.map((e) => e.path); + + expect(paths).toContain(join("sub", "legit.ts")); + expect(paths).not.toContain(join("sub", "escape-link")); + }); }); describe("read-files", () => { From c5660b895b11002dc8863cd5723de4e9cf329b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 6 Apr 2026 15:41:40 +0200 Subject: [PATCH 5/6] fix(init): remove double-close of Dir handle in walkDir for-await-of auto-closes the Dir handle on completion (including break). The finally block was double-closing it, which throws ERR_DIR_CLOSED on Node.js (masked by Bun which silently succeeds). Matches the existing pattern in env-file.ts and project-root.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/init/local-ops.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index b5183fa78..8ba83b02e 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -411,6 +411,7 @@ async function walkDir( return; } + // No explicit handle.close() needed: for-await-of auto-closes the Dir try { for await (const entry of handle) { if (ctx.entries.length >= ctx.maxEntries) { @@ -420,8 +421,6 @@ async function walkDir( } } catch { // Directory unreadable (ENOENT, EACCES, etc.) — skip gracefully - } finally { - await handle.close(); } } From 5a5053d3c632d8887ccd2a55a0f26ddc3bad95b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 6 Apr 2026 16:04:40 +0200 Subject: [PATCH 6/6] refactor(init): simplify listDir to use readdir like code-scanner Replace opendir + 4 extracted helpers with a straightforward readdir walk matching the pattern in code-scanner.ts. Simpler, avoids the opendir symlink-following and double-close pitfalls, and uses the same biome suppression approach as collectFiles. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/init/local-ops.ts | 125 ++++++++++++++------------------------ 1 file changed, 44 insertions(+), 81 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 8ba83b02e..fa15e83f9 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -346,98 +346,61 @@ export async function handleLocalOp( } } -/** Directory names that are listed but never recursed into. */ -const SKIP_DIRS = new Set(["node_modules"]); - -/** Return true if a symlink escapes the project directory. */ -function isEscapingSymlink(cwd: string, relPath: string): boolean { - try { - safePath(cwd, relPath); - return false; - } catch { - return true; - } -} - -/** Whether a directory entry should be recursed into. */ -function shouldRecurse(entry: fs.Dirent): boolean { - if (!entry.isDirectory() || entry.isSymbolicLink()) { - return false; - } - return !(entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)); -} +async function listDir(payload: ListDirPayload): Promise { + const { cwd, params } = payload; + const targetPath = safePath(cwd, params.path); + const maxDepth = params.maxDepth ?? 3; + const maxEntries = params.maxEntries ?? 500; + const recursive = params.recursive ?? false; -type WalkContext = { - cwd: string; - recursive: boolean; - maxDepth: number; - maxEntries: number; - entries: DirEntry[]; -}; + const entries: DirEntry[] = []; -/** Process a single dirent during directory walking. */ -async function processDirEntry( - ctx: WalkContext, - dir: string, - entry: fs.Dirent, - depth: number -): Promise { - const relPath = path.relative(ctx.cwd, path.join(dir, entry.name)); - if (entry.isSymbolicLink() && isEscapingSymlink(ctx.cwd, relPath)) { - return; - } + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: recursive directory walk is inherently complex but straightforward + async function walk(dir: string, depth: number): Promise { + if (entries.length >= maxEntries || depth > maxDepth) { + return; + } - const type = entry.isDirectory() ? "directory" : "file"; - ctx.entries.push({ name: entry.name, path: relPath, type }); + let dirEntries: fs.Dirent[]; + try { + dirEntries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return; + } - if (ctx.recursive && shouldRecurse(entry)) { - await walkDir(ctx, path.join(dir, entry.name), depth + 1); - } -} + for (const entry of dirEntries) { + if (entries.length >= maxEntries) { + return; + } -async function walkDir( - ctx: WalkContext, - dir: string, - depth: number -): Promise { - if (ctx.entries.length >= ctx.maxEntries || depth > ctx.maxDepth) { - return; - } + const relPath = path.relative(cwd, path.join(dir, entry.name)); - let handle: fs.Dir; - try { - handle = await fs.promises.opendir(dir, { bufferSize: 1024 }); - } catch { - return; - } + // Skip symlinks that escape the project directory + if (entry.isSymbolicLink()) { + try { + safePath(cwd, relPath); + } catch { + continue; + } + } - // No explicit handle.close() needed: for-await-of auto-closes the Dir - try { - for await (const entry of handle) { - if (ctx.entries.length >= ctx.maxEntries) { - break; + const type = entry.isDirectory() ? "directory" : "file"; + entries.push({ name: entry.name, path: relPath, type }); + + if ( + recursive && + entry.isDirectory() && + !entry.isSymbolicLink() && + !entry.name.startsWith(".") && + entry.name !== "node_modules" + ) { + await walk(path.join(dir, entry.name), depth + 1); } - await processDirEntry(ctx, dir, entry, depth); } - } catch { - // Directory unreadable (ENOENT, EACCES, etc.) — skip gracefully } -} - -async function listDir(payload: ListDirPayload): Promise { - const { cwd, params } = payload; - const targetPath = safePath(cwd, params.path); - - const ctx: WalkContext = { - cwd, - recursive: params.recursive ?? false, - maxDepth: params.maxDepth ?? 3, - maxEntries: params.maxEntries ?? 500, - entries: [], - }; - await walkDir(ctx, targetPath, 0); - return { ok: true, data: { entries: ctx.entries } }; + await walk(targetPath, 0); + return { ok: true, data: { entries } }; } async function readSingleFile(