Skip to content

Commit 53a70d8

Browse files
fix(files): skip broken symlinks during directory scans
1 parent 074c7d1 commit 53a70d8

2 files changed

Lines changed: 80 additions & 4 deletions

File tree

‎backend/src/services/file-operations.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,23 @@ export async function listDirectory(dirPath: string): Promise<Array<{
135135
if (entry.name === '.' || entry.name === '..') continue
136136

137137
const entryPath = path.join(fullPath, entry.name)
138-
const stats = await fs.stat(entryPath)
138+
let stats: Awaited<ReturnType<typeof fs.stat>>
139+
try {
140+
stats = await fs.stat(entryPath)
141+
} catch (error) {
142+
const code = (error as NodeJS.ErrnoException).code
143+
if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'ELOOP') {
144+
logger.warn(`Skipping unresolvable directory entry ${entryPath}:`, error)
145+
continue
146+
}
147+
throw error
148+
}
139149

140150
result.push({
141151
name: entry.name,
142152
path: entryPath,
143-
isDirectory: entry.isDirectory(),
144-
size: entry.isDirectory() ? 0 : stats.size,
153+
isDirectory: stats.isDirectory(),
154+
size: stats.isDirectory() ? 0 : stats.size,
145155
lastModified: stats.mtime
146156
})
147157
}

‎backend/test/services/skills.test.ts‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest'
22
import { join } from 'path'
33
import { tmpdir } from 'os'
4-
import { mkdir, mkdtemp, rm, readFile, writeFile } from 'fs/promises'
4+
import { mkdir, mkdtemp, rm, readFile, writeFile, symlink } from 'fs/promises'
5+
import { promises as fs } from 'fs'
56
import type { OpenCodeClient } from '../../src/services/opencode/client'
67
import type { Repo } from '../../src/types/repo'
78

@@ -341,6 +342,71 @@ describe('SkillService', () => {
341342
}
342343
})
343344

345+
test('keeps healthy skills when dangling symlinks sit beside them', async () => {
346+
const { listManagedSkills } = await import('../../src/services/skills')
347+
const projectPath = join(tempDir, 'project-symlinks')
348+
const skillsRoot = join(projectPath, '.opencode', 'skills')
349+
350+
const regularDir = join(skillsRoot, 'regular-skill')
351+
await mkdir(regularDir, { recursive: true })
352+
await writeFile(
353+
join(regularDir, 'SKILL.md'),
354+
'---\nname: regular-skill\ndescription: Regular skill\n---\nBody',
355+
)
356+
357+
const symlinkTargetDir = join(tempDir, 'symlink-target')
358+
await mkdir(symlinkTargetDir, { recursive: true })
359+
await writeFile(
360+
join(symlinkTargetDir, 'SKILL.md'),
361+
'---\nname: linked-skill\ndescription: Linked skill\n---\nBody',
362+
)
363+
await symlink(symlinkTargetDir, join(skillsRoot, 'linked-skill'), 'dir')
364+
365+
await symlink(join(tempDir, 'missing-target'), join(skillsRoot, 'dangling-skill'), 'dir')
366+
367+
const plainFile = join(tempDir, 'plain-file.txt')
368+
await writeFile(plainFile, 'not a directory')
369+
await symlink(join(plainFile, 'child'), join(skillsRoot, 'notdir-skill'), 'dir')
370+
371+
await symlink(join(skillsRoot, 'loop-b'), join(skillsRoot, 'loop-a'), 'dir')
372+
await symlink(join(skillsRoot, 'loop-a'), join(skillsRoot, 'loop-b'), 'dir')
373+
374+
const skills = await listManagedSkills(mockDb, createMockClient([]), undefined, projectPath)
375+
376+
expect(skills).toContainEqual(expect.objectContaining({
377+
name: 'regular-skill',
378+
description: 'Regular skill',
379+
scope: 'project',
380+
}))
381+
expect(skills).toContainEqual(expect.objectContaining({
382+
name: 'linked-skill',
383+
description: 'Linked skill',
384+
scope: 'project',
385+
}))
386+
expect(skills.map(s => s.name)).not.toContain('dangling-skill')
387+
})
388+
389+
test('listDirectory rejects when the root directory is missing', async () => {
390+
const { listDirectory } = await import('../../src/services/file-operations')
391+
await expect(listDirectory(join(tempDir, 'missing-root'))).rejects.toThrow('Failed to list directory')
392+
})
393+
394+
test('listDirectory propagates unexpected stat errors', async () => {
395+
const { listDirectory } = await import('../../src/services/file-operations')
396+
const dir = join(tempDir, 'stat-error-root')
397+
await mkdir(dir, { recursive: true })
398+
await writeFile(join(dir, 'entry.txt'), 'content')
399+
400+
const statSpy = vi.spyOn(fs, 'stat').mockRejectedValue(
401+
Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }),
402+
)
403+
try {
404+
await expect(listDirectory(dir)).rejects.toThrow('Failed to list directory')
405+
} finally {
406+
statSpy.mockRestore()
407+
}
408+
})
409+
344410
test('preserves body content containing --- (horizontal rules)', async () => {
345411
const { createSkill, getSkill, deleteSkill } = await import('../../src/services/skills')
346412
const name = `hr-test-${Date.now()}`

0 commit comments

Comments
 (0)