diff --git a/__tests__/symlink-security.spec.ts b/__tests__/symlink-security.spec.ts index 955372e..612b4b5 100644 --- a/__tests__/symlink-security.spec.ts +++ b/__tests__/symlink-security.spec.ts @@ -24,6 +24,15 @@ describe('symlink security', () => { expect(fs.readFileSync(filePath, 'utf8')).toBe('# new'); }); + test('loadMdFiles includes normal .md files', async () => { + const filePath = path.join(tmpDir, 'normal.md'); + fs.writeFileSync(filePath, '# normal'); + + const result = await loadMdFiles([path.join(tmpDir, '*.md')], []); + + expect(result).toContain(filePath); + }); + test('loadMdFiles filters .md symlinks', async () => { const realFile = path.join(tmpDir, 'real.md'); const symlinkFile = path.join(tmpDir, 'link.md'); @@ -36,6 +45,70 @@ describe('symlink security', () => { expect(result).not.toContain(symlinkFile); }); + test('loadMdFiles does not filter files inside symlinked directories', async () => { + const realDir = path.join(tmpDir, 'real'); + const linkDir = path.join(tmpDir, 'link'); + fs.mkdirSync(realDir); + fs.writeFileSync(path.join(realDir, 'a.md'), '# a'); + fs.symlinkSync(realDir, linkDir, 'dir'); + + const result = await loadMdFiles([path.join(tmpDir, '**', '*.md')], []); + + const realResult = path.join(realDir, 'a.md'); + const linkResult = path.join(linkDir, 'a.md'); + expect(result).toContain(realResult); + expect(result).toContain(linkResult); + // Note: safeWriteFile rejects a symlink as the final path component, + // but it does not prevent traversal through symlinked parent directories. + // This test documents the current loadMdFiles behavior. + }); + + test('loadMdFiles deduplicates overlapping patterns', async () => { + const filePath = path.join(tmpDir, 'dup.md'); + fs.writeFileSync(filePath, '# dup'); + + const result = await loadMdFiles( + [path.join(tmpDir, '*.md'), path.join(tmpDir, '*.md')], + [] + ); + + expect(result).toHaveLength(1); + expect(result).toContain(filePath); + }); + + test('loadMdFiles returns absolute paths for relative patterns', async () => { + const filePath = path.join(tmpDir, 'rel.md'); + fs.writeFileSync(filePath, '# rel'); + + const cwd = process.cwd(); + const relativePattern = path.relative(cwd, path.join(tmpDir, '*.md')); + + const result = await loadMdFiles([relativePattern], []); + + expect(result.length).toBeGreaterThanOrEqual(1); + expect(path.isAbsolute(result[0])).toBe(true); + }); + + test('loadMdFiles handles nonexistent paths gracefully', async () => { + const result = await loadMdFiles([path.join(tmpDir, 'no-such-*.md')], []); + + expect(result).toEqual([]); + }); + + test('loadMdFiles respects extensions filter', async () => { + fs.writeFileSync(path.join(tmpDir, 'a.md'), '# a'); + fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'text'); + + const result = await loadMdFiles( + [path.join(tmpDir, '*')], + [], + ['.md'] + ); + + expect(result).toContain(path.join(tmpDir, 'a.md')); + expect(result).not.toContain(path.join(tmpDir, 'b.txt')); + }); + test('safeWriteFile rejects symlink', async () => { const target = path.join(tmpDir, 'target.md'); const symlink = path.join(tmpDir, 'link.md'); diff --git a/src/utils/load-md-files.ts b/src/utils/load-md-files.ts index 0476799..7d1db97 100644 --- a/src/utils/load-md-files.ts +++ b/src/utils/load-md-files.ts @@ -1,11 +1,11 @@ -import { lstat } from 'fs/promises'; import { glob } from 'glob'; /** * 读取所有文件 * - * @param globList {string} 用户传入的文件数组 + * @param globList {string[]} 用户传入的文件数组 * @param excludeFiles {string[]} 忽略的文件 + * @param extensions {string[]} 文件扩展名 * @returns {Promise} 读取到的文件数组 */ export const loadMdFiles = async ( @@ -13,35 +13,22 @@ export const loadMdFiles = async ( excludeFiles: string[], extensions: string[] = ['.md', '.markdown', '.mdx'] ) => { - const filePaths = await Promise.all( - // 先把 globList 去重,防止执行多余的 glob 查询 - [...new Set(globList)].map((fileList) => { - return glob(`${fileList}`, { - ignore: excludeFiles, - absolute: true, - nodir: true, - follow: false, - }); - }) - ); - - const filtered = ([...new Set(filePaths.flat())] as string[]).filter((item) => { - return extensions.some(ext => item.endsWith(ext)); + const entries = await glob([...new Set(globList)], { + ignore: excludeFiles, + withFileTypes: true, + nodir: true, + follow: false, }); - // lstat 过滤:跳过符号链接,只忽略 ENOENT,其他错误继续抛出 - const stats = await Promise.all( - filtered.map(async (f) => { - try { - return await lstat(f); - } - catch (e: any) { - if (e.code === 'ENOENT') - return null; - throw e; - } - }) - ); + const files = new Set(); + + for (const entry of entries) { + if (entry.isSymbolicLink()) + continue; + const fullPath = entry.fullpath(); + if (extensions.some(ext => fullPath.endsWith(ext))) + files.add(fullPath); + } - return filtered.filter((_, i) => stats[i] !== null && !stats[i]!.isSymbolicLink()); + return [...files]; }; diff --git a/src/utils/sanitize-terminal.ts b/src/utils/sanitize-terminal.ts index 5df0c78..7af3f77 100644 --- a/src/utils/sanitize-terminal.ts +++ b/src/utils/sanitize-terminal.ts @@ -15,7 +15,8 @@ export function sanitizeTerminalText(text: string): string { // 替换所有 C0 控制字符和 DEL 为可见转义 // eslint-disable-next-line no-control-regex result = result.replace(/[\x00-\x1F\x7F]/g, (ch) => { - if (ch === '\x7F') return '^?'; + if (ch === '\x7F') + return '^?'; return `^${String.fromCharCode(ch.charCodeAt(0) + 0x40)}`; }); return result;