Fix bug not to respect symbolic linked rules, if target is a directory or another symbolic link (#2513)

* read symbolic linked dir and files recursively

* add symlinked dir and nested symlink test case for custom-instructions

* enhance comments

* add changeset
This commit is contained in:
Taisuke Oe 2025-04-12 12:32:57 +09:00 committed by GitHub
parent 2eba534dd6
commit e453690e7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 132 additions and 31 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Fix bug not to respect symbolic linked rules, if target is a directory or another symbolic link

View file

@ -615,30 +615,64 @@ describe("Rules directory reading", () => {
} as any)
// Simulate listing files including a symlink
readdirMock.mockResolvedValueOnce([
{
name: "regular.txt",
isFile: () => true,
isSymbolicLink: () => false,
parentPath: "/fake/path/.roo/rules",
},
{ name: "link.txt", isFile: () => false, isSymbolicLink: () => true, parentPath: "/fake/path/.roo/rules" },
] as any)
readdirMock
.mockResolvedValueOnce([
{
name: "regular.txt",
isFile: () => true,
isSymbolicLink: () => false,
parentPath: "/fake/path/.roo/rules",
},
{
name: "link.txt",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/fake/path/.roo/rules",
},
{
name: "link_dir",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/fake/path/.roo/rules",
},
{
name: "nested_link.txt",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/fake/path/.roo/rules",
},
] as any)
.mockResolvedValueOnce([
{ name: "subdir_link.txt", isFile: () => true, parentPath: "/fake/path/.roo/rules/symlink-target-dir" },
] as any)
// Simulate readlink response
readlinkMock.mockResolvedValueOnce("../symlink-target.txt")
readlinkMock
.mockResolvedValueOnce("../symlink-target.txt")
.mockResolvedValueOnce("../symlink-target-dir")
.mockResolvedValueOnce("../nested-symlink")
.mockResolvedValueOnce("nested-symlink-target.txt")
// Reset and set up the stat mock with more granular control
statMock.mockReset()
statMock.mockImplementation((path: string) => {
// For directory check
if (path === "/fake/path/.roo/rules") {
if (path === "/fake/path/.roo/rules" || path.endsWith("dir")) {
return Promise.resolve({
isDirectory: jest.fn().mockReturnValue(true),
isFile: jest.fn().mockReturnValue(false),
} as any)
}
// For symlink check
if (path.endsWith("symlink")) {
return Promise.resolve({
isDirectory: jest.fn().mockReturnValue(false),
isFile: jest.fn().mockReturnValue(false),
isSymbolicLink: jest.fn().mockReturnValue(true),
} as any)
}
// For all files
return Promise.resolve({
isFile: jest.fn().mockReturnValue(true),
@ -654,6 +688,12 @@ describe("Rules directory reading", () => {
if (filePath.toString() === "/fake/path/.roo/rules/../symlink-target.txt") {
return Promise.resolve("symlink target content")
}
if (filePath.toString() === "/fake/path/.roo/rules/symlink-target-dir/subdir_link.txt") {
return Promise.resolve("regular file content under symlink target dir")
}
if (filePath.toString() === "/fake/path/.roo/rules/../nested-symlink-target.txt") {
return Promise.resolve("nested symlink target content")
}
return Promise.reject({ code: "ENOENT" })
})
@ -664,13 +704,20 @@ describe("Rules directory reading", () => {
expect(result).toContain("regular file content")
expect(result).toContain("# Rules from /fake/path/.roo/rules/../symlink-target.txt:")
expect(result).toContain("symlink target content")
expect(result).toContain("# Rules from /fake/path/.roo/rules/symlink-target-dir/subdir_link.txt:")
expect(result).toContain("regular file content under symlink target dir")
expect(result).toContain("# Rules from /fake/path/.roo/rules/../nested-symlink-target.txt:")
expect(result).toContain("nested symlink target content")
// Verify readlink was called with the symlink path
expect(readlinkMock).toHaveBeenCalledWith("/fake/path/.roo/rules/link.txt")
expect(readlinkMock).toHaveBeenCalledWith("/fake/path/.roo/rules/link_dir")
// Verify both files were read
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/regular.txt", "utf-8")
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/../symlink-target.txt", "utf-8")
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/symlink-target-dir/subdir_link.txt", "utf-8")
expect(readFileMock).toHaveBeenCalledWith("/fake/path/.roo/rules/../nested-symlink-target.txt", "utf-8")
})
beforeEach(() => {
jest.clearAllMocks()

View file

@ -2,6 +2,7 @@ import fs from "fs/promises"
import path from "path"
import { LANGUAGES, isLanguage } from "../../../shared/language"
import { Dirent } from "fs"
/**
* Safely read a file and return its trimmed content
@ -31,6 +32,68 @@ async function directoryExists(dirPath: string): Promise<boolean> {
}
}
const MAX_DEPTH = 5
/**
* Recursively resolve directory entries and collect file paths
*/
async function resolveDirectoryEntry(
entry: Dirent,
dirPath: string,
filePaths: string[],
depth: number,
): Promise<void> {
// Avoid cyclic symlinks
if (depth > MAX_DEPTH) {
return
}
const fullPath = path.resolve(entry.parentPath || dirPath, entry.name)
if (entry.isFile()) {
// Regular file
filePaths.push(fullPath)
} else if (entry.isSymbolicLink()) {
// Await the resolution of the symbolic link
await resolveSymLink(fullPath, filePaths, depth + 1)
}
}
/**
* Recursively resolve a symbolic link and collect file paths
*/
async function resolveSymLink(fullPath: string, filePaths: string[], depth: number): Promise<void> {
// Avoid cyclic symlinks
if (depth > MAX_DEPTH) {
return
}
try {
// Get the symlink target
const linkTarget = await fs.readlink(fullPath)
// Resolve the target path (relative to the symlink location)
const resolvedTarget = path.resolve(path.dirname(fullPath), linkTarget)
// Check if the target is a file
const stats = await fs.stat(resolvedTarget)
if (stats.isFile()) {
filePaths.push(resolvedTarget)
} else if (stats.isDirectory()) {
const anotherEntries = await fs.readdir(resolvedTarget, { withFileTypes: true, recursive: true })
// Collect promises for recursive calls within the directory
const directoryPromises: Promise<void>[] = []
for (const anotherEntry of anotherEntries) {
directoryPromises.push(resolveDirectoryEntry(anotherEntry, resolvedTarget, filePaths, depth + 1))
}
// Wait for all entries in the resolved directory to be processed
await Promise.all(directoryPromises)
} else if (stats.isSymbolicLink()) {
// Handle nested symlinks by awaiting the recursive call
await resolveSymLink(resolvedTarget, filePaths, depth + 1)
}
} catch (err) {
// Skip invalid symlinks
}
}
/**
* Read all text files from a directory in alphabetical order
*/
@ -40,30 +103,16 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
// Process all entries - regular files and symlinks that might point to files
const filePaths: string[] = []
// Collect promises for the initial resolution calls
const initialPromises: Promise<void>[] = []
for (const entry of entries) {
const fullPath = path.resolve(entry.parentPath || dirPath, entry.name)
if (entry.isFile()) {
// Regular file
filePaths.push(fullPath)
} else if (entry.isSymbolicLink()) {
try {
// Get the symlink target
const linkTarget = await fs.readlink(fullPath)
// Resolve the target path (relative to the symlink location)
const resolvedTarget = path.resolve(path.dirname(fullPath), linkTarget)
// Check if the target is a file
const stats = await fs.stat(resolvedTarget)
if (stats.isFile()) {
filePaths.push(resolvedTarget)
}
} catch (err) {
// Skip invalid symlinks
}
}
initialPromises.push(resolveDirectoryEntry(entry, dirPath, filePaths, 0))
}
// Wait for all asynchronous operations (including recursive ones) to complete
await Promise.all(initialPromises)
const fileContents = await Promise.all(
filePaths.map(async (file) => {
try {