Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions __tests__/symlink-security.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
47 changes: 17 additions & 30 deletions src/utils/load-md-files.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,34 @@
import { lstat } from 'fs/promises';
import { glob } from 'glob';

/**
* 读取所有文件
*
* @param globList {string} 用户传入的文件数组
* @param globList {string[]} 用户传入的文件数组
* @param excludeFiles {string[]} 忽略的文件
* @param extensions {string[]} 文件扩展名
* @returns {Promise<string[]>} 读取到的文件数组
*/
export const loadMdFiles = async (
globList: string[],
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<string>();

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];
};
3 changes: 2 additions & 1 deletion src/utils/sanitize-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down