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
80 changes: 80 additions & 0 deletions src/core/prompts/sections/__tests__/custom-instructions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,15 @@ const statMock = vi.fn()
const readdirMock = vi.fn()
const readlinkMock = vi.fn()
const lstatMock = vi.fn()
const realpathMock = vi.fn().mockImplementation((p: string) => Promise.resolve(p))

// Replace fs functions with our mocks
fs.readFile = readFileMock as any
fs.stat = statMock as any
fs.readdir = readdirMock as any
fs.readlink = readlinkMock as any
fs.lstat = lstatMock as any
fs.realpath = realpathMock as any

// Mock process.cwd
const originalCwd = process.cwd
Expand Down Expand Up @@ -1573,6 +1575,84 @@ describe("Rules directory reading", () => {
expect(result).toContain("mmm-middle.txt")
})

it.skipIf(process.platform === "win32")(
"should resolve relative symlinks using realpath of parent directory",
async () => {
// This tests the scenario where:
// /project/.roo/rules -> /external/rules (symlinked directory)
// /external/rules/1-project.txt -> ../1-project.txt (relative symlink)
// The relative symlink should resolve to /external/1-project.txt, NOT /project/.roo/1-project.txt

// Simulate .roo/rules directory exists
statMock.mockResolvedValueOnce({
isDirectory: vi.fn().mockReturnValue(true),
} as any)

// Simulate listing files - one relative symlink inside the symlinked directory
readdirMock.mockResolvedValueOnce([
{
name: "1-project.txt",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/project/.roo/rules",
},
] as any)

// readlink returns a relative target
readlinkMock.mockResolvedValueOnce("../1-project.txt")

// realpath resolves the symlinked parent directory to its real location
realpathMock.mockImplementation((dirPath: string) => {
const normalizedPath = dirPath.toString().replace(/\\/g, "/")
if (normalizedPath === "/project/.roo/rules") {
return Promise.resolve("/external/rules")
}
return Promise.resolve(dirPath)
})

// stat mock for the resolved target
statMock.mockImplementation((filePath: string) => {
const normalizedPath = filePath.toString().replace(/\\/g, "/")
if (normalizedPath === "/external/rules/../1-project.txt" || normalizedPath === "/external/1-project.txt") {
return Promise.resolve({
isFile: vi.fn().mockReturnValue(true),
isDirectory: vi.fn().mockReturnValue(false),
} as any)
}
return Promise.resolve({
isFile: vi.fn().mockReturnValue(false),
isDirectory: vi.fn().mockReturnValue(false),
} as any)
})

readFileMock.mockImplementation((filePath: string) => {
const normalizedPath = filePath.toString().replace(/\\/g, "/")
if (normalizedPath === "/external/rules/../1-project.txt" || normalizedPath === "/external/1-project.txt") {
return Promise.resolve("content from external file")
}
return Promise.reject({ code: "ENOENT" })
})

const result = await loadRuleFiles("/project")

// Verify realpath was called on the symlink's parent directory
expect(realpathMock).toHaveBeenCalledWith("/project/.roo/rules")

// Verify the content was read from the correctly resolved path
// (resolved relative to /external/rules, not /project/.roo/rules)
expect(result).toContain("content from external file")

// Verify readlink was called
expect(readlinkMock).toHaveBeenCalledWith("/project/.roo/rules/1-project.txt")

// The key assertion: stat should NOT have been called with the wrong path
// that would result from resolving relative to the symlinked path
const statCalls = statMock.mock.calls.map((call: any[]) => call[0].toString().replace(/\\/g, "/"))
expect(statCalls).not.toContain("/project/.roo/rules/../1-project.txt")
expect(statCalls).not.toContain("/project/.roo/1-project.txt")
},
)

it("should handle empty file list gracefully", async () => {
// Simulate .roo/rules directory exists
statMock.mockResolvedValueOnce({
Expand Down
15 changes: 13 additions & 2 deletions src/core/prompts/sections/custom-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,19 @@ async function resolveSymLink(
try {
// Get the symlink target
const linkTarget = await fs.readlink(symlinkPath)
// Resolve the target path (relative to the symlink location)
const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget)
// Resolve the target path relative to the symlink's real parent directory.
// We must use realpath on the parent directory because the symlink itself
// may be inside a symlinked directory. Relative symlink targets are resolved
// by the filesystem relative to the symlink's actual location, not the path
// used to access it.
const symlinkDir = path.dirname(symlinkPath)
let realSymlinkDir: string
try {
realSymlinkDir = await fs.realpath(symlinkDir)
} catch {
realSymlinkDir = symlinkDir
}
const resolvedTarget = path.resolve(realSymlinkDir, linkTarget)

// Check if the target is a file
const stats = await fs.stat(resolvedTarget)
Expand Down
Loading