import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises' import { join, extname, dirname, basename } from 'path' import { randomBytes } from 'crypto' import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types' import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants' const ALLOWED_EXTENSIONS_SET = new Set(ALLOWED_EXTENSIONS) export async function readFileContent(filePath: string): Promise { try { const fileStat = await stat(filePath) if (fileStat.size > MAX_FILE_SIZE) { return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` } } // L-01: 检测并剥离 BOM(UTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF) const buffer = await readFile(filePath) let content: string if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) { // UTF-16 BE: swap bytes to LE 再解码 const swapped = Buffer.allocUnsafe(buffer.length) buffer.copy(swapped) swapped.swap16() content = swapped.toString('utf-16le') if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) } else if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) { // UTF-16 LE content = buffer.toString('utf-16le') if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) } else { // UTF-8 (含 FEFF BOM 剥离) content = buffer.toString('utf-8') if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) } return { success: true, content } } catch (err) { return { success: false, error: (err as Error).message } } } // C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败) // M-01: 使用随机 hex 后缀替代 Date.now(),防止本地攻击者预创建 symlink export async function saveFileContent(filePath: string, content: string): Promise { const randomSuffix = randomBytes(8).toString('hex') const tmpFile = join(dirname(filePath), `.marklite-tmp-${randomSuffix}-${basename(filePath)}`) try { await writeFile(tmpFile, content, 'utf-8') await rename(tmpFile, filePath) return { success: true, filePath } } catch (err) { // 清理残留临时文件 try { await unlink(tmpFile) } catch { /* ignore */ } return { success: false, error: (err as Error).message } } } // M-05: 递归深度限制 + 错误边界 + 符号链接循环检测 export async function buildDirTree( dirPath: string, depth = 0, maxDepth = 10, visited?: Set ): Promise { if (depth > maxDepth) return [] if (!visited) visited = new Set() // 检测符号链接循环 let realPath: string try { const ls = await lstat(dirPath) if (ls.isSymbolicLink()) return [] realPath = await realpath(dirPath) } catch { return [] } if (visited.has(realPath)) return [] visited.add(realPath) let entries try { entries = await readdir(dirPath, { withFileTypes: true }) } catch { return [] } entries.sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1 if (!a.isDirectory() && b.isDirectory()) return 1 return a.name.localeCompare(b.name) }) const children: FileNode[] = [] for (const entry of entries) { if (SKIP_DIRS.has(entry.name)) continue if (entry.name.startsWith('.')) continue const childPath = join(dirPath, entry.name) if (entry.isDirectory()) { const subChildren = await buildDirTree(childPath, depth + 1, maxDepth, visited) if (subChildren.length > 0) { children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren }) } } else { const ext = extname(entry.name).toLowerCase() if (ALLOWED_EXTENSIONS_SET.has(ext)) { children.push({ name: entry.name, path: childPath, type: 'file' }) } } } return children }