mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
Add centralized path validation to prevent path traversal attacks. - PathValidator class with sync/async validation methods - Protects against '..' and '~' traversal patterns - Validates paths are within project root - Refactored all 7 file tools to use PathValidator - 51 new tests for PathValidator
275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
|
import { promises as fs } from "node:fs"
|
|
import * as path from "node:path"
|
|
import * as os from "node:os"
|
|
import {
|
|
GetStructureTool,
|
|
type GetStructureResult,
|
|
} from "../../../../../src/infrastructure/tools/read/GetStructureTool.js"
|
|
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
|
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
|
|
|
function createMockStorage(): IStorage {
|
|
return {
|
|
getFile: vi.fn(),
|
|
setFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
getAllFiles: vi.fn(),
|
|
getAST: vi.fn(),
|
|
setAST: vi.fn(),
|
|
getMeta: vi.fn(),
|
|
setMeta: vi.fn(),
|
|
getSymbolIndex: vi.fn(),
|
|
setSymbolIndex: vi.fn(),
|
|
getDepsGraph: vi.fn(),
|
|
setDepsGraph: vi.fn(),
|
|
getConfig: vi.fn(),
|
|
setConfig: vi.fn(),
|
|
clear: vi.fn(),
|
|
} as unknown as IStorage
|
|
}
|
|
|
|
function createMockContext(projectRoot: string): ToolContext {
|
|
return {
|
|
projectRoot,
|
|
storage: createMockStorage(),
|
|
requestConfirmation: vi.fn().mockResolvedValue(true),
|
|
onProgress: vi.fn(),
|
|
}
|
|
}
|
|
|
|
describe("GetStructureTool", () => {
|
|
let tool: GetStructureTool
|
|
let tempDir: string
|
|
|
|
beforeEach(async () => {
|
|
tool = new GetStructureTool()
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ipuaro-test-"))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempDir, { recursive: true, force: true })
|
|
})
|
|
|
|
describe("metadata", () => {
|
|
it("should have correct name", () => {
|
|
expect(tool.name).toBe("get_structure")
|
|
})
|
|
|
|
it("should have correct category", () => {
|
|
expect(tool.category).toBe("read")
|
|
})
|
|
|
|
it("should not require confirmation", () => {
|
|
expect(tool.requiresConfirmation).toBe(false)
|
|
})
|
|
|
|
it("should have correct parameters", () => {
|
|
expect(tool.parameters).toHaveLength(2)
|
|
expect(tool.parameters[0].name).toBe("path")
|
|
expect(tool.parameters[0].required).toBe(false)
|
|
expect(tool.parameters[1].name).toBe("depth")
|
|
expect(tool.parameters[1].required).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("validateParams", () => {
|
|
it("should return null for empty params", () => {
|
|
expect(tool.validateParams({})).toBeNull()
|
|
})
|
|
|
|
it("should return null for valid path", () => {
|
|
expect(tool.validateParams({ path: "src" })).toBeNull()
|
|
})
|
|
|
|
it("should return null for valid depth", () => {
|
|
expect(tool.validateParams({ depth: 3 })).toBeNull()
|
|
})
|
|
|
|
it("should return error for non-string path", () => {
|
|
expect(tool.validateParams({ path: 123 })).toBe("Parameter 'path' must be a string")
|
|
})
|
|
|
|
it("should return error for non-integer depth", () => {
|
|
expect(tool.validateParams({ depth: 2.5 })).toBe("Parameter 'depth' must be an integer")
|
|
})
|
|
|
|
it("should return error for depth < 1", () => {
|
|
expect(tool.validateParams({ depth: 0 })).toBe("Parameter 'depth' must be >= 1")
|
|
})
|
|
})
|
|
|
|
describe("execute", () => {
|
|
it("should return tree structure for empty directory", async () => {
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GetStructureResult
|
|
expect(data.path).toBe(".")
|
|
expect(data.tree.type).toBe("directory")
|
|
expect(data.tree.children).toEqual([])
|
|
expect(data.stats.directories).toBe(1)
|
|
expect(data.stats.files).toBe(0)
|
|
})
|
|
|
|
it("should return tree structure with files", async () => {
|
|
await fs.writeFile(path.join(tempDir, "file1.ts"), "")
|
|
await fs.writeFile(path.join(tempDir, "file2.ts"), "")
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GetStructureResult
|
|
expect(data.tree.children).toHaveLength(2)
|
|
expect(data.stats.files).toBe(2)
|
|
expect(data.content).toContain("file1.ts")
|
|
expect(data.content).toContain("file2.ts")
|
|
})
|
|
|
|
it("should return nested directory structure", async () => {
|
|
await fs.mkdir(path.join(tempDir, "src"))
|
|
await fs.writeFile(path.join(tempDir, "src", "index.ts"), "")
|
|
await fs.mkdir(path.join(tempDir, "src", "utils"))
|
|
await fs.writeFile(path.join(tempDir, "src", "utils", "helper.ts"), "")
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GetStructureResult
|
|
expect(data.stats.directories).toBe(3)
|
|
expect(data.stats.files).toBe(2)
|
|
expect(data.content).toContain("src")
|
|
expect(data.content).toContain("index.ts")
|
|
expect(data.content).toContain("utils")
|
|
expect(data.content).toContain("helper.ts")
|
|
})
|
|
|
|
it("should respect depth parameter", async () => {
|
|
await fs.mkdir(path.join(tempDir, "level1"))
|
|
await fs.mkdir(path.join(tempDir, "level1", "level2"))
|
|
await fs.mkdir(path.join(tempDir, "level1", "level2", "level3"))
|
|
await fs.writeFile(path.join(tempDir, "level1", "level2", "level3", "deep.ts"), "")
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({ depth: 2 }, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GetStructureResult
|
|
expect(data.content).toContain("level1")
|
|
expect(data.content).toContain("level2")
|
|
expect(data.content).not.toContain("level3")
|
|
expect(data.content).not.toContain("deep.ts")
|
|
})
|
|
|
|
it("should filter subdirectory when path specified", async () => {
|
|
await fs.mkdir(path.join(tempDir, "src"))
|
|
await fs.mkdir(path.join(tempDir, "tests"))
|
|
await fs.writeFile(path.join(tempDir, "src", "index.ts"), "")
|
|
await fs.writeFile(path.join(tempDir, "tests", "test.ts"), "")
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({ path: "src" }, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GetStructureResult
|
|
expect(data.path).toBe("src")
|
|
expect(data.content).toContain("index.ts")
|
|
expect(data.content).not.toContain("test.ts")
|
|
})
|
|
|
|
it("should ignore node_modules", async () => {
|
|
await fs.mkdir(path.join(tempDir, "node_modules"))
|
|
await fs.writeFile(path.join(tempDir, "node_modules", "pkg.js"), "")
|
|
await fs.writeFile(path.join(tempDir, "index.ts"), "")
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GetStructureResult
|
|
expect(data.content).not.toContain("node_modules")
|
|
expect(data.content).toContain("index.ts")
|
|
})
|
|
|
|
it("should ignore .git directory", async () => {
|
|
await fs.mkdir(path.join(tempDir, ".git"))
|
|
await fs.writeFile(path.join(tempDir, ".git", "config"), "")
|
|
await fs.writeFile(path.join(tempDir, "index.ts"), "")
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GetStructureResult
|
|
expect(data.content).not.toContain(".git")
|
|
})
|
|
|
|
it("should sort directories before files", async () => {
|
|
await fs.writeFile(path.join(tempDir, "aaa.ts"), "")
|
|
await fs.mkdir(path.join(tempDir, "zzz"))
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GetStructureResult
|
|
const zzzIndex = data.content.indexOf("zzz")
|
|
const aaaIndex = data.content.indexOf("aaa.ts")
|
|
expect(zzzIndex).toBeLessThan(aaaIndex)
|
|
})
|
|
|
|
it("should return error for path outside project root", async () => {
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({ path: "../outside" }, ctx)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe("Path contains traversal patterns")
|
|
})
|
|
|
|
it("should return error for non-directory path", async () => {
|
|
await fs.writeFile(path.join(tempDir, "file.ts"), "")
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({ path: "file.ts" }, ctx)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toContain("is not a directory")
|
|
})
|
|
|
|
it("should return error for non-existent path", async () => {
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({ path: "nonexistent" }, ctx)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toContain("ENOENT")
|
|
})
|
|
|
|
it("should include callId in result", async () => {
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({}, ctx)
|
|
|
|
expect(result.callId).toMatch(/^get_structure-\d+$/)
|
|
})
|
|
|
|
it("should use tree icons in output", async () => {
|
|
await fs.mkdir(path.join(tempDir, "src"))
|
|
await fs.writeFile(path.join(tempDir, "index.ts"), "")
|
|
const ctx = createMockContext(tempDir)
|
|
|
|
const result = await tool.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GetStructureResult
|
|
expect(data.content).toContain("📁")
|
|
expect(data.content).toContain("📄")
|
|
})
|
|
})
|
|
})
|