Files
MarkLite/src/main/file-system.ts
T
thzxx 7f070eb11d chore: bump version to 0.3.8
Code audit fixes:
- CRITICAL: reorder Markdown pipeline (fixImages before sanitize)
- CRITICAL: fix path prefix separator check
- BLOCKING: remove duplicate useEffect in Editor
- BLOCKING: skip onChange when content unchanged
- BLOCKING: optimize Sidebar re-render with useMemo
- HIGH: cleanup FileWatcher polling intervals
- HIGH: improve validatePath segment check
- HIGH: fix isExternalUpdate race with counter
- HIGH: add will-navigate / setWindowOpenHandler
- HIGH: explicit strip in sanitize schema
- MEDIUM: random temp file suffix instead of Date.now()
- MEDIUM/LOW: add IndexedDB error boundaries
- LOW: support UTF-16 BOM detection
2026-06-18 21:28:35 +08:00

112 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>(ALLOWED_EXTENSIONS)
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
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: 检测并剥离 BOMUTF-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<SaveFileResult> {
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<string>
): Promise<FileNode[]> {
if (depth > maxDepth) return []
if (!visited) visited = new Set<string>()
// 检测符号链接循环
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
}