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
26 changes: 25 additions & 1 deletion common/src/testing/mocks/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mock } from 'bun:test'

import type { CodebuffFileSystem } from '../../types/filesystem'
import type { Mock } from 'bun:test'
import type { PathLike , Stats } from 'node:fs'
import type { PathLike, Stats } from 'node:fs'

export interface CreateMockFsOptions {
files?: Record<string, string>
Expand All @@ -14,6 +14,7 @@ export interface CreateMockFsOptions {
path: string,
options?: { recursive?: boolean },
) => Promise<string | undefined>
realpathImpl?: (path: string) => Promise<string>
statImpl?: (path: string) => Promise<Stats>
}

Expand All @@ -31,6 +32,7 @@ export interface MockFsWithMocks {
options?: { recursive?: boolean },
) => Promise<string | undefined>
>
realpath: Mock<(path: PathLike) => Promise<string>>
stat: Mock<(path: PathLike) => Promise<Stats>>
}

Expand All @@ -43,6 +45,7 @@ export function createMockFs(options: CreateMockFsOptions = {}): MockFs {
readdirImpl,
writeFileImpl,
mkdirImpl,
realpathImpl,
statImpl,
} = options

Expand Down Expand Up @@ -79,6 +82,20 @@ export function createMockFs(options: CreateMockFsOptions = {}): MockFs {
return undefined
}

const defaultRealpath = async (path: PathLike): Promise<string> => {
const pathStr = String(path)
const isKnownPath =
pathStr in writtenFiles ||
pathStr in directories ||
createdDirs.has(pathStr)

if (!isKnownPath) {
throw new Error(`Path not found: ${pathStr}`)
}

return pathStr
}

const defaultStat = async (path: PathLike): Promise<Stats> => {
const pathStr = String(path)
const isFile = pathStr in writtenFiles
Expand Down Expand Up @@ -134,6 +151,10 @@ export function createMockFs(options: CreateMockFsOptions = {}): MockFs {
mkdirImpl(String(path), opts)
: defaultMkdir

const realpathFn = realpathImpl
? async (path: PathLike) => realpathImpl(String(path))
: defaultRealpath

const statFn = statImpl
? async (path: PathLike) => statImpl(String(path))
: defaultStat
Expand All @@ -143,6 +164,7 @@ export function createMockFs(options: CreateMockFsOptions = {}): MockFs {
readdir: mock(readdirFn),
writeFile: mock(writeFileFn),
mkdir: mock(mkdirFn),
realpath: mock(realpathFn),
stat: mock(statFn),
} as unknown as MockFs
}
Expand All @@ -153,6 +175,7 @@ export function restoreMockFs(mockFs: MockFs): void {
mocks.readdir.mockRestore()
mocks.writeFile.mockRestore()
mocks.mkdir.mockRestore()
mocks.realpath.mockRestore()
mocks.stat.mockRestore()
}

Expand All @@ -162,5 +185,6 @@ export function clearMockFs(mockFs: MockFs): void {
mocks.readdir.mockClear()
mocks.writeFile.mockClear()
mocks.mkdir.mockClear()
mocks.realpath.mockClear()
mocks.stat.mockClear()
}
8 changes: 7 additions & 1 deletion common/src/types/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,11 @@ import type fs from 'fs'
*/
export type CodebuffFileSystem = Pick<
typeof fs.promises,
'mkdir' | 'readdir' | 'readFile' | 'stat' | 'unlink' | 'writeFile'
| 'mkdir'
| 'readdir'
| 'readFile'
| 'realpath'
| 'stat'
| 'unlink'
| 'writeFile'
>
224 changes: 224 additions & 0 deletions sdk/src/__tests__/list-directory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { describe, expect, it, mock } from 'bun:test'

import path from 'path'

import { listDirectory } from '../tools/list-directory'

import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem'
import type { Dirent, PathLike } from 'node:fs'

const PROJECT_ROOT = path.resolve('workspace', 'project')

function createFs(realpaths: Record<string, string>) {
const readdir = mock(async (_path: PathLike) => {
return [
{
name: 'index.ts',
isDirectory: () => false,
isFile: () => true,
},
] as Dirent[]
})

const fs = {
realpath: mock(async (path: PathLike) => {
const pathString = String(path)
return realpaths[pathString] ?? pathString
}),
readdir,
} as unknown as CodebuffFileSystem

return { fs, readdir }
}

describe('listDirectory', () => {
it('allows listing the project root itself', async () => {
const { fs, readdir } = createFs({
[PROJECT_ROOT]: PROJECT_ROOT,
})

const result = await listDirectory({
directoryPath: '.',
projectPath: PROJECT_ROOT,
fs,
})

expect(result[0]).toEqual({
type: 'json',
value: {
files: ['index.ts'],
directories: [],
path: '.',
},
})
expect(readdir).toHaveBeenCalledWith(PROJECT_ROOT, {
withFileTypes: true,
})
})

it('lists a directory inside the project and preserves the requested path', async () => {
const childPath = path.join(PROJECT_ROOT, 'src')
const { fs, readdir } = createFs({
[PROJECT_ROOT]: PROJECT_ROOT,
[childPath]: childPath,
})

const result = await listDirectory({
directoryPath: 'src',
projectPath: PROJECT_ROOT,
fs,
})

expect(result).toEqual([
{
type: 'json',
value: {
files: ['index.ts'],
directories: [],
path: 'src',
},
},
])
expect(readdir).toHaveBeenCalledWith(childPath, {
withFileTypes: true,
})
})

it('returns the normal list error when the requested directory is missing', async () => {
const missingPath = path.join(PROJECT_ROOT, 'missing')
const readdir = mock(async (_path: PathLike) => [] as Dirent[])
const fs = {
realpath: mock(async (requestedPath: PathLike) => {
const requestedPathString = String(requestedPath)
if (requestedPathString === missingPath) {
throw new Error(
`ENOENT: no such file or directory, realpath '${missingPath}'`,
)
}
return requestedPathString
}),
readdir,
} as unknown as CodebuffFileSystem

const result = await listDirectory({
directoryPath: 'missing',
projectPath: PROJECT_ROOT,
fs,
})

expect(result).toEqual([
{
type: 'json',
value: {
errorMessage: `Failed to list directory: ENOENT: no such file or directory, realpath '${missingPath}'`,
},
},
])
expect(readdir).not.toHaveBeenCalled()
})

it('rejects sibling paths that only share the project prefix', async () => {
const siblingPath = path.resolve(PROJECT_ROOT, '..', 'project-evil')
const { fs, readdir } = createFs({
[PROJECT_ROOT]: PROJECT_ROOT,
[siblingPath]: siblingPath,
})

const result = await listDirectory({
directoryPath: '../project-evil',
projectPath: PROJECT_ROOT,
fs,
})

expect(result).toEqual([
{
type: 'json',
value: {
errorMessage:
"Invalid path: Path '../project-evil' is outside the project directory.",
},
},
])
expect(readdir).not.toHaveBeenCalled()
})

it('rejects the project parent directory', async () => {
const parentPath = path.dirname(PROJECT_ROOT)
const { fs, readdir } = createFs({
[PROJECT_ROOT]: PROJECT_ROOT,
[parentPath]: parentPath,
})

const result = await listDirectory({
directoryPath: '..',
projectPath: PROJECT_ROOT,
fs,
})

expect(result).toEqual([
{
type: 'json',
value: {
errorMessage:
"Invalid path: Path '..' is outside the project directory.",
},
},
])
expect(readdir).not.toHaveBeenCalled()
})

it('rejects directories that escape through a symlink', async () => {
const symlinkPath = path.join(PROJECT_ROOT, 'link')
const outsidePath = path.resolve(PROJECT_ROOT, '..', 'outside')
const { fs, readdir } = createFs({
[PROJECT_ROOT]: PROJECT_ROOT,
[symlinkPath]: outsidePath,
})

const result = await listDirectory({
directoryPath: 'link',
projectPath: PROJECT_ROOT,
fs,
})

expect(result).toEqual([
{
type: 'json',
value: {
errorMessage:
"Invalid path: Path 'link' is outside the project directory.",
},
},
])
expect(readdir).not.toHaveBeenCalled()
})

it('allows a symlink that resolves inside the project', async () => {
const symlinkPath = path.join(PROJECT_ROOT, 'link')
const realTarget = path.join(PROJECT_ROOT, 'src')
const { fs, readdir } = createFs({
[PROJECT_ROOT]: PROJECT_ROOT,
[symlinkPath]: realTarget,
})

const result = await listDirectory({
directoryPath: 'link',
projectPath: PROJECT_ROOT,
fs,
})

expect(result).toEqual([
{
type: 'json',
value: {
files: ['index.ts'],
directories: [],
path: 'link',
},
},
])
expect(readdir).toHaveBeenCalledWith(realTarget, {
withFileTypes: true,
})
})
})
19 changes: 17 additions & 2 deletions sdk/src/tools/list-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as path from 'path'

import type { CodebuffToolOutput } from '@codebuff/common/tools/list'
import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem'
import { isPathInside } from '@codebuff/common/util/path'

export async function listDirectory(params: {
directoryPath: string
Expand All @@ -11,9 +12,23 @@ export async function listDirectory(params: {
const { directoryPath, projectPath, fs } = params

try {
const resolvedPath = path.resolve(projectPath, directoryPath)
const projectRoot = path.resolve(projectPath)
const resolvedPath = path.resolve(projectRoot, directoryPath)
const realProjectRoot = await fs.realpath(projectRoot)
const realResolvedPath = await fs.realpath(resolvedPath)

const entries = await fs.readdir(resolvedPath, {
if (!isPathInside(realProjectRoot, realResolvedPath)) {
return [
{
type: 'json',
value: {
errorMessage: `Invalid path: Path '${directoryPath}' is outside the project directory.`,
},
},
]
}

const entries = await fs.readdir(realResolvedPath, {
withFileTypes: true,
})

Expand Down
Loading