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, SearchInDirPayload, SearchInDirResult, } 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 // B14-fix: 检测原文件编码(UTF-16 LE/BE BOM),保存时保持同编码, // 避免 UTF-16 文件被强制改写为 UTF-8 造成乱码 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 { let data: Buffer try { const existing = await readFile(filePath) if (existing.length >= 2 && existing[0] === 0xfe && existing[1] === 0xff) { // 原文件 UTF-16 BE:BOM + BE 编码写回 const bom = Buffer.from([0xfe, 0xff]) const body = Buffer.from(content, 'utf16le') body.swap16() data = Buffer.concat([bom, body]) } else if (existing.length >= 2 && existing[0] === 0xff && existing[1] === 0xfe) { // 原文件 UTF-16 LE:BOM + LE 编码写回 const bom = Buffer.from([0xff, 0xfe]) const body = Buffer.from(content, 'utf16le') data = Buffer.concat([bom, body]) } else { data = Buffer.from(content, 'utf-8') } } catch { // 原文件不存在/不可读:按 UTF-8 写新文件 data = Buffer.from(content, 'utf-8') } await writeFile(tmpFile, data) 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 } // v0.6.2: 多文件搜索 — 递归遍历目录,逐行匹配 md/markdown/txt 文件内容 const SEARCH_MAX_MATCHES = 500 const SEARCH_MAX_DEPTH = 10 const SEARCH_LINE_PREVIEW_LEN = 200 function buildSearchMatcher( query: string, caseSensitive: boolean, useRegex: boolean, ): { test: (line: string) => boolean } | null { if (useRegex) { try { const re = new RegExp(query, caseSensitive ? '' : 'i') return { test: (line: string) => re.test(line) } } catch { return null // 无效正则 } } const needle = caseSensitive ? query : query.toLowerCase() return { test: (line: string) => (caseSensitive ? line : line.toLowerCase()).includes(needle), } } export async function searchInDir( payload: SearchInDirPayload, ): Promise { const { dirPath, query, caseSensitive = false, useRegex = false } = payload if (!query) { return { success: false, error: '搜索内容为空' } } const matcher = buildSearchMatcher(query, caseSensitive, useRegex) if (!matcher) { return { success: false, error: '无效的正则表达式' } } const matches: SearchInDirResult['matches'] = [] let totalFiles = 0 let truncated = false const walk = async (dir: string, depth: number): Promise => { if (truncated || depth > SEARCH_MAX_DEPTH) return let entries try { entries = await readdir(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { if (truncated) return const name = entry.name if (SKIP_DIRS.has(name) || name.startsWith('.')) continue const childPath = join(dir, name) if (entry.isDirectory()) { await walk(childPath, depth + 1) continue } if (!ALLOWED_EXTENSIONS_SET.has(extname(name).toLowerCase())) continue totalFiles++ try { const fileStat = await stat(childPath) if (fileStat.size > MAX_FILE_SIZE) continue const content = await readFile(childPath, 'utf-8') const lines = content.split('\n') for (let i = 0; i < lines.length; i++) { if (!matcher.test(lines[i])) continue matches?.push({ filePath: childPath, line: i + 1, lineText: lines[i].slice(0, SEARCH_LINE_PREVIEW_LEN), }) if ((matches?.length ?? 0) >= SEARCH_MAX_MATCHES) { truncated = true return } } } catch { // 单文件读取失败(编码/权限)跳过,不中断整体搜索 } } } try { await walk(dirPath, 0) return { success: true, matches, totalFiles, truncated } } catch (err) { return { success: false, error: (err as Error).message } } }