Skip to content

Commit 90e983e

Browse files
committed
fix: properly handle symbolic links to directories
- Check if symbolic links point to files before processing - Prevent attempting to read directories as files - Add test coverage for symlinks pointing to directories
1 parent 7c16f25 commit 90e983e

File tree

2 files changed

+78
-11
lines changed

2 files changed

+78
-11
lines changed

src/services/command/__tests__/symlink-commands.spec.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,14 @@ description: Deploys the application
3535
3636
Deploy instructions.`
3737

38-
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
38+
mockFs.stat = vi
39+
.fn()
40+
// First call for directory check
41+
.mockResolvedValueOnce({ isDirectory: () => true } as any)
42+
// Subsequent calls for symlink target checks
43+
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false } as any) // setup.md symlink points to file
44+
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false } as any) // build.md symlink points to file
45+
3946
mockFs.readdir = vi.fn().mockResolvedValue([
4047
{ name: "setup.md", isFile: () => false, isSymbolicLink: () => true }, // Symbolic link
4148
{ name: "deploy.md", isFile: () => true, isSymbolicLink: () => false }, // Regular file
@@ -86,10 +93,17 @@ description: Linked command
8693
8794
This command is accessed via symbolic link.`
8895

89-
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
96+
// Mock directory stat
97+
mockFs.stat = vi
98+
.fn()
99+
// First call for directory check
100+
.mockResolvedValueOnce({ isDirectory: () => true } as any)
101+
// Second call for symlink target check (linked.md points to a file)
102+
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false } as any)
103+
90104
mockFs.readdir = vi.fn().mockResolvedValue([
91105
{ name: "test.md", isFile: () => true, isSymbolicLink: () => false }, // Regular file
92-
{ name: "linked.md", isFile: () => false, isSymbolicLink: () => true }, // Symbolic link
106+
{ name: "linked.md", isFile: () => false, isSymbolicLink: () => true }, // Symbolic link to file
93107
{ name: "not-markdown.txt", isFile: () => true, isSymbolicLink: () => false }, // Should be ignored
94108
{ name: "symlink.txt", isFile: () => false, isSymbolicLink: () => true }, // Non-markdown symlink, should be ignored
95109
])
@@ -118,15 +132,16 @@ This command is accessed via symbolic link.`
118132
119133
This is a valid command.`
120134

121-
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
135+
mockFs.stat = vi
136+
.fn()
137+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // Directory check
138+
.mockRejectedValueOnce(new Error("ENOENT: no such file or directory")) // Broken symlink
139+
122140
mockFs.readdir = vi.fn().mockResolvedValue([
123141
{ name: "valid.md", isFile: () => true, isSymbolicLink: () => false }, // Regular file
124142
{ name: "broken-link.md", isFile: () => false, isSymbolicLink: () => true }, // Broken symbolic link
125143
])
126-
mockFs.readFile = vi
127-
.fn()
128-
.mockResolvedValueOnce(validContent)
129-
.mockRejectedValueOnce(new Error("ENOENT: no such file or directory")) // Broken link
144+
mockFs.readFile = vi.fn().mockResolvedValueOnce(validContent)
130145

131146
const result = await getCommands("/test/cwd")
132147

@@ -139,6 +154,35 @@ This is a valid command.`
139154
)
140155
})
141156

157+
it("should ignore symbolic links pointing to directories", async () => {
158+
const validContent = `# Valid Command
159+
160+
This is a valid command.`
161+
162+
mockFs.stat = vi
163+
.fn()
164+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // Commands directory check
165+
.mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true } as any) // dir-link.md points to a directory
166+
167+
mockFs.readdir = vi.fn().mockResolvedValue([
168+
{ name: "valid.md", isFile: () => true, isSymbolicLink: () => false }, // Regular file
169+
{ name: "dir-link.md", isFile: () => false, isSymbolicLink: () => true }, // Symbolic link to directory
170+
])
171+
mockFs.readFile = vi.fn().mockResolvedValueOnce(validContent)
172+
173+
const result = await getCommands("/test/cwd")
174+
175+
// Should only load the valid command, ignoring the symlink to directory
176+
expect(result).toHaveLength(1)
177+
expect(result[0]).toEqual(
178+
expect.objectContaining({
179+
name: "valid",
180+
}),
181+
)
182+
// readFile should only be called once (for the valid file)
183+
expect(mockFs.readFile).toHaveBeenCalledTimes(1)
184+
})
185+
142186
it("should prioritize project symbolic links over global commands", async () => {
143187
const projectSymlinkContent = `---
144188
description: Project symbolic link command
@@ -157,7 +201,11 @@ description: Global command
157201
Global command content.`
158202

159203
// Mock both directories
160-
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
204+
mockFs.stat = vi
205+
.fn()
206+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // Global directory
207+
.mockResolvedValueOnce({ isDirectory: () => true } as any) // Project directory
208+
.mockResolvedValueOnce({ isFile: () => true, isDirectory: () => false } as any) // Project symlink target
161209

162210
// First call for global directory scan, second for project directory scan
163211
mockFs.readdir = vi

src/services/command/commands.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,28 @@ async function scanCommandDirectory(
136136
const entries = await fs.readdir(dirPath, { withFileTypes: true })
137137

138138
for (const entry of entries) {
139-
// Check for both regular files and symbolic links
140-
if ((entry.isFile() || entry.isSymbolicLink()) && isMarkdownFile(entry.name)) {
139+
// Only process markdown files
140+
if (isMarkdownFile(entry.name)) {
141141
const filePath = path.join(dirPath, entry.name)
142+
143+
// Check if entry is a valid file or a symbolic link pointing to a file
144+
let isValidFile = false
145+
if (entry.isFile()) {
146+
isValidFile = true
147+
} else if (entry.isSymbolicLink()) {
148+
try {
149+
const stats = await fs.stat(filePath) // follows the symlink
150+
isValidFile = stats.isFile()
151+
} catch {
152+
// Broken symlink or points to non-existent target
153+
isValidFile = false
154+
}
155+
}
156+
157+
if (!isValidFile) {
158+
continue
159+
}
160+
142161
const commandName = getCommandNameFromFile(entry.name)
143162

144163
try {

0 commit comments

Comments
 (0)