Skip to content
Open
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
22 changes: 17 additions & 5 deletions src/core/walker.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Gitignore-aware recursive directory walker with depth control
// Returns filtered file paths respecting project ignore patterns

import { readdir, readFile, stat } from "fs/promises";
import { join, relative, resolve } from "path";
import { readdir, readFile, realpath, stat } from "fs/promises";
import { isAbsolute, join, relative, resolve } from "path";
import ignore, { type Ignore } from "ignore";

export interface WalkOptions {
Expand Down Expand Up @@ -38,6 +38,11 @@ const ALWAYS_IGNORE = new Set([
".parcel-cache",
]);

function isWithinRoot(rootDir: string, targetPath: string): boolean {
const relPath = relative(rootDir, targetPath);
return relPath === "" || (!relPath.startsWith("..") && !isAbsolute(relPath));
}

async function loadIgnoreRules(rootDir: string): Promise<Ignore> {
const ig = ignore();
try {
Expand Down Expand Up @@ -76,16 +81,23 @@ async function walkRecursive(
export async function walkDirectory(options: WalkOptions): Promise<FileEntry[]> {
const rootDir = resolve(options.rootDir);
const startDir = options.targetPath ? resolve(rootDir, options.targetPath) : rootDir;
const ig = await loadIgnoreRules(rootDir);
const results: FileEntry[] = [];

let rootRealPath: string;
let startRealPath: string;
try {
await stat(startDir);
[rootRealPath, startRealPath] = await Promise.all([realpath(rootDir), realpath(startDir)]);
await stat(startRealPath);
} catch {
return results;
}

await walkRecursive(startDir, rootDir, ig, 0, options.depthLimit ?? 0, results);
if (!isWithinRoot(rootRealPath, startRealPath)) {
throw new Error(`Path traversal denied: "${options.targetPath}" resolves outside root directory`);
}

const ig = await loadIgnoreRules(rootRealPath);
await walkRecursive(startRealPath, rootRealPath, ig, 0, options.depthLimit ?? 0, results);
return results;
}

Expand Down
47 changes: 46 additions & 1 deletion test/main/walker.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { walkDirectory, groupByDirectory } from "../../build/core/walker.js";
import { writeFile, mkdir, rm } from "fs/promises";
import { writeFile, mkdir, rm, symlink } from "fs/promises";
import { join } from "path";

const FIXTURE_DIR = join(process.cwd(), "test", "_walk_fixtures");
Expand Down Expand Up @@ -95,6 +95,51 @@ describe("walker", () => {
assert.equal(entries.length, 0);
});

it("rejects targetPath traversal outside root", async () => {
await assert.rejects(
walkDirectory({
rootDir: FIXTURE_DIR,
targetPath: "..",
}),
/Path traversal denied/,
);
});

it("rejects targetPath traversal to sibling paths with shared prefixes", async () => {
const siblingDir = `${FIXTURE_DIR}-sibling`;
await mkdir(siblingDir, { recursive: true });
try {
await assert.rejects(
walkDirectory({
rootDir: FIXTURE_DIR,
targetPath: "../_walk_fixtures-sibling",
}),
/Path traversal denied/,
);
} finally {
await rm(siblingDir, { recursive: true, force: true });
}
});

it("rejects symlink escapes passed as targetPath", async () => {
const outsideDir = `${FIXTURE_DIR}-outside`;
const linkPath = join(FIXTURE_DIR, "outside-link");
await mkdir(outsideDir, { recursive: true });
try {
await symlink(outsideDir, linkPath, "dir");
await assert.rejects(
walkDirectory({
rootDir: FIXTURE_DIR,
targetPath: "outside-link",
}),
/Path traversal denied/,
);
} finally {
await rm(linkPath, { recursive: true, force: true });
await rm(outsideDir, { recursive: true, force: true });
}
});

it("includes depth info", async () => {
const entries = await walkDirectory({ rootDir: FIXTURE_DIR });
const topLevel = entries.filter((e) => e.depth === 0);
Expand Down