fix: resolve relative symlinks in rules files using realpath of parent directory

When a .roo/rules directory is itself a symlink to an external directory,
and files inside are relative symlinks (e.g., ../1-project.txt), the
symlink targets were incorrectly resolved relative to the access path
instead of the real filesystem path.

Use fs.realpath() on the symlink's parent directory before resolving
relative targets, matching how the OS resolves relative symlinks.
This commit is contained in:
Povilas Kanapickas 2026-02-27 21:09:42 +02:00
parent 54721a3155
commit 7b721931ee
2 changed files with 93 additions and 2 deletions

View file

@ -64,6 +64,7 @@ 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
@ -71,6 +72,7 @@ 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
@ -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({

View file

@ -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)