From 3b16978f8997ba7fefe9a4bd3a3d0e6aa4d9e7ae Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sun, 5 Jul 2026 22:38:28 +0800 Subject: [PATCH 1/3] feat(load-md-files): replace lstat with glob withFileTypes, eliminate unbounded Promise.all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite loadMdFiles using glob({ withFileTypes: true }) to eliminate all lstat system calls — isSymbolicLink() reads Dirent type bits directly - Pass all patterns as array to single glob() call, removing the first Promise.all - Remove absolute: true (conflicts with withFileTypes), use fullpath() - Add 6 new tests: normal files, file symlinks, files inside symlinked dirs, pattern dedup, relative→absolute paths, nonexistent paths - Update performance-security-analysis.md: mark P1 as fixed, reorder priorities --- .gitignore | 2 + __tests__/symlink-security.spec.ts | 73 ++++++++++++++++++++++++++++++ src/utils/load-md-files.ts | 47 +++++++------------ src/utils/sanitize-terminal.ts | 3 +- 4 files changed, 94 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 15a68c2..2ccb9ad 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ esm lib examples/chinese + +docs/ 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; From 58b48fc92155dd8cc93ec5936b8f603b5ad75340 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sun, 5 Jul 2026 22:56:04 +0800 Subject: [PATCH 2/3] ci(gitignore): revert --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 2ccb9ad..aaffa36 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,4 @@ pnpm-lock.yaml esm lib -examples/chinese - -docs/ +examples/chinese \ No newline at end of file From 79fa4731b1a3d3bf145ab6525f89fda1993aeebe Mon Sep 17 00:00:00 2001 From: luojiyin Date: Sun, 5 Jul 2026 22:58:38 +0800 Subject: [PATCH 3/3] ci(gitignore): revert --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index aaffa36..15a68c2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,4 @@ pnpm-lock.yaml esm lib -examples/chinese \ No newline at end of file +examples/chinese