Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions ix-cli/src/cli/__tests__/text-workspace-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn<(args: string[]) => 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<void> {
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);
});
});
40 changes: 34 additions & 6 deletions ix-cli/src/cli/commands/text.ts
Original file line number Diff line number Diff line change
@@ -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 <term>")
.description("Fast lexical/text search across the codebase (uses ripgrep)")
.option("--limit <n>", "Max results", "20")
.option("--path <dir>", "Restrict search to a directory", ".")
.option("--path <dir>", "Restrict search to a workspace-relative directory", ".")
.option("--language <lang>", "Filter by language (python, typescript, scala, etc.)")
.option("--format <fmt>", "Output format (text|json|llm)", "text")
.option("--root <dir>", "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",
Expand All @@ -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")) {
Expand All @@ -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 ?? "",
Expand Down
14 changes: 11 additions & 3 deletions ix-cli/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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),
);
}

Expand Down
Loading