diff --git a/ix-cli/src/cli/__tests__/text-workspace-boundary.test.ts b/ix-cli/src/cli/__tests__/text-workspace-boundary.test.ts new file mode 100644 index 00000000..ec159a43 --- /dev/null +++ b/ix-cli/src/cli/__tests__/text-workspace-boundary.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerTextCommand, resolveTextSearchPath } from "../commands/text.js"; + +describe("text workspace boundary", () => { + let fixture: string; + let workspace: string; + let outside: string; + let originalCwd: string; + let originalExitCode: number | string | undefined; + let logs: string[]; + let runRipgrep: ReturnType Promise<{ stdout: string }>>>; + + beforeEach(() => { + fixture = mkdtempSync(join(tmpdir(), "ix-text-boundary-")); + workspace = join(fixture, "workspace"); + outside = join(fixture, "outside"); + mkdirSync(join(workspace, "src"), { recursive: true }); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(workspace, "src", "inside.ts"), "export const boundaryNeedle = true;\n"); + writeFileSync(join(outside, "outside.ts"), "export const boundaryNeedle = false;\n"); + originalCwd = process.cwd(); + originalExitCode = process.exitCode; + process.chdir(outside); + process.exitCode = undefined; + logs = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.join(" ")); + }); + runRipgrep = vi.fn(async () => ({ + stdout: JSON.stringify({ + type: "match", + data: { + path: { text: join(workspace, "src", "inside.ts") }, + line_number: 1, + lines: { text: "export const boundaryNeedle = true;\n" }, + }, + }), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.chdir(originalCwd); + process.exitCode = originalExitCode; + rmSync(fixture, { recursive: true, force: true }); + }); + + async function run(args: string[]): Promise { + const program = new Command(); + registerTextCommand(program, runRipgrep); + await program.parseAsync(["node", "ix", ...args]); + } + + it("resolves --path relative to --root instead of the process cwd", async () => { + expect(resolveTextSearchPath(workspace, "src")).toBe(join(workspace, "src")); + + await run(["text", "boundaryNeedle", "--root", workspace, "--path", "src", "--format", "json"]); + + expect(runRipgrep).toHaveBeenCalledWith(expect.arrayContaining(["boundaryNeedle", join(workspace, "src")])); + expect(JSON.parse(logs.join("\n"))).toMatchObject([{ path: "src/inside.ts" }]); + expect(process.exitCode).toBeUndefined(); + }); + + it("rejects an absolute search path outside the explicit workspace", async () => { + await run(["text", "boundaryNeedle", "--root", workspace, "--path", outside, "--format", "json"]); + + expect(JSON.parse(logs.join("\n"))).toMatchObject({ error: "path_outside_workspace" }); + expect(process.exitCode).toBe(1); + }); + + it.skipIf(process.platform === "win32")("rejects a symlink that leaves the workspace", async () => { + symlinkSync(outside, join(workspace, "linked"), "dir"); + + await run(["text", "boundaryNeedle", "--root", workspace, "--path", "linked", "--format", "json"]); + + expect(JSON.parse(logs.join("\n"))).toMatchObject({ error: "path_outside_workspace" }); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/ix-cli/src/cli/commands/text.ts b/ix-cli/src/cli/commands/text.ts index b0e7491f..ac8add1d 100644 --- a/ix-cli/src/cli/commands/text.ts +++ b/ix-cli/src/cli/commands/text.ts @@ -1,25 +1,50 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import path from "node:path"; import type { Command } from "commander"; import { formatTextResults, type TextResult } from "../format.js"; -import { resolveWorkspaceRoot } from "../config.js"; +import { isPathInsideResolvedRoot, resolveWorkspaceRoot } from "../config.js"; import { stderr } from "../stderr.js"; +import { llmError } from "../llm.js"; const execFileAsync = promisify(execFile); -export function registerTextCommand(program: Command): void { +type RunRipgrep = (args: string[]) => Promise<{ stdout: string }>; + +async function runRipgrep(args: string[]): Promise<{ stdout: string }> { + return execFileAsync("rg", args, { maxBuffer: 10 * 1024 * 1024 }); +} + +export function resolveTextSearchPath(root: string, searchPath: string): string { + return path.resolve(root, searchPath); +} + +export function registerTextCommand(program: Command, executeRipgrep: RunRipgrep = runRipgrep): void { program .command("text ") .description("Fast lexical/text search across the codebase (uses ripgrep)") .option("--limit ", "Max results", "20") - .option("--path ", "Restrict search to a directory", ".") + .option("--path ", "Restrict search to a workspace-relative directory", ".") .option("--language ", "Filter by language (python, typescript, scala, etc.)") .option("--format ", "Output format (text|json|llm)", "text") .option("--root ", "Workspace root directory") .addHelpText("after", "\nExamples:\n ix text verify_token --language python\n ix text \"class.*Service\" --limit 10 --format json\n ix text TODO --path src/") .action(async (term: string, opts: { limit: string; path: string; format: string; language?: string; root?: string }) => { const limit = parseInt(opts.limit, 10); - const searchPath = opts.path !== "." ? opts.path : resolveWorkspaceRoot(opts.root); + const root = path.resolve(resolveWorkspaceRoot(opts.root)); + const searchPath = resolveTextSearchPath(root, opts.path); + if (!isPathInsideResolvedRoot(root, searchPath)) { + const message = `Search path is outside the workspace: ${opts.path}`; + if (opts.format === "json") { + console.log(JSON.stringify({ error: "path_outside_workspace", message }, null, 2)); + } else if (opts.format === "llm") { + console.log(llmError("path_outside_workspace", message)); + } else { + stderr(`Error: ${message}`); + } + process.exitCode = 1; + return; + } try { const rgArgs = [ "--json", @@ -34,7 +59,7 @@ export function registerTextCommand(program: Command): void { rgArgs.push(term, searchPath); - const { stdout } = await execFileAsync("rg", rgArgs, { maxBuffer: 10 * 1024 * 1024 }); + const { stdout } = await executeRipgrep(rgArgs); const results: TextResult[] = []; for (const line of stdout.split("\n")) { @@ -44,9 +69,12 @@ export function registerTextCommand(program: Command): void { if (parsed.type === "match") { const data = parsed.data; const filePath = data.path?.text ?? ""; + const absoluteFilePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(process.cwd(), filePath); const lineNum = data.line_number ?? 0; results.push({ - path: filePath, + path: path.relative(root, absoluteFilePath).split(path.sep).join("/"), line_start: lineNum, line_end: lineNum, snippet: data.lines?.text ?? "", diff --git a/ix-cli/src/cli/config.ts b/ix-cli/src/cli/config.ts index cbbc2591..9cf73747 100644 --- a/ix-cli/src/cli/config.ts +++ b/ix-cli/src/cli/config.ts @@ -223,6 +223,16 @@ export function isPathInside(root: string, candidate: string): boolean { return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); } +/** + * Is a path contained by one specific root after resolving symlinks on both + * sides? This is stricter than `isReadablePath`, which deliberately allows all + * registered workspace roots for cross-workspace reads. + */ +export function isPathInsideResolvedRoot(root: string, candidate: string): boolean { + const real = (p: string) => { try { return realpathSync(p); } catch { return resolvePath(p); } }; + return isPathInside(root, candidate) && isPathInside(real(root), real(candidate)); +} + /** * The roots a read command may open a file from: the workspace this invocation * resolves to, plus every workspace the user has registered with `ix init`. @@ -248,10 +258,8 @@ export function readableRoots(explicitRoot?: string): string[] { * path stands in, which is the same answer for everything that is not a link. */ export function isReadablePath(candidate: string, explicitRoot?: string): boolean { - const real = (p: string) => { try { return realpathSync(p); } catch { return resolvePath(p); } }; - const realCandidate = real(candidate); return readableRoots(explicitRoot).some( - root => isPathInside(root, candidate) && isPathInside(real(root), realCandidate), + root => isPathInsideResolvedRoot(root, candidate), ); }