mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
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:
parent
54721a3155
commit
7b721931ee
2 changed files with 93 additions and 2 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue