This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
// 第三轮全面审计回归:覆盖第二、三轮的所有修复点。
//
// 涵盖:
// 安全 S1 - dataDir 允许根目录白名单home / documents / downloads / desktop / userData / appData
// 安全 S2 - dataDir realpath 解析后再次校验(防止 symlink 绕过白名单)
// 安全 S3 - loadConfig 过滤 __proto__/constructor/prototype 防止原型污染
// 安全 S4 - 路径不合法 / 不存在时抛错而非 console.warn 静默吞掉
// 错误处理 E1 - app.js bootstrap 有 .bak 自动回退
// 错误处理 E2 - task-list addTask 返回 null 时弹 toast
// 错误处理 E3 - task-store loadFromContent parseMarkdown 抛错时 emit load-failed
// 数据完整性 D1 - markdown-writer 写入 originalCategory 转义 ]
// 数据完整性 D2 - markdown-parser 读回 originalCategory 反转义
// 数据完整性 D3 - 多行任务文本 \\n 保留
// 性能 P1 - task-list SVG 字面量提升STAR_SVG_FILLED 等)
// 性能 P2 - task-list 拖拽 rAF 节流pendingMove + rafScheduled
// 性能 P3 - task-list _flushPendingDrag 在 pointerup 同步落点
// 性能 P4 - app.js setupStatusBar 文本相同则跳过 DOM 写入
// 并发 C1 - feedback.js 模块级监听器 AbortController + disposeFeedbackGlobals
// 并发 C2 - app.js export disposeBootstrap() 可取消所有 bootstrap 监听器
// 卫生 H1 - app.js 没有未限定的 console.log保留 console.error / console.warn
import { readFileSync } from 'node:fs';
import { check, ok, bad, printSummary } from './_lib/check.mjs';
const MAIN = readFileSync('main.js', 'utf8');
const APP = readFileSync('src/app.js', 'utf8');
const TASK_LIST = readFileSync('src/ui/task-list.js', 'utf8');
const TASK_STORE = readFileSync('src/task-store.js', 'utf8');
const FEEDBACK = readFileSync('src/ui/feedback.js', 'utf8');
const MARKDOWN_WRITER = readFileSync('src/markdown-writer.js', 'utf8');
const MARKDOWN_PARSER = readFileSync('src/markdown-parser.js', 'utf8');
function section(name) { console.log(`\n[${name}]`); }
// ─────────────────────────────────────────────────────────────────────────────
// 安全 S1+S2: dataDir 允许根目录白名单 + realpath 防 symlink 绕过
// ─────────────────────────────────────────────────────────────────────────────
section('安全: dataDir 白名单 + symlink 防御');
check(
'main.js dataDir 校验使用 app.getPath("home") 等白名单',
/app\.getPath\(['"]home['"]\)/.test(MAIN),
'白名单是 home / documents / downloads / desktop / userData / appData'
);
check(
'main.js dataDir 校验使用 app.getPath("documents")',
/app\.getPath\(['"]documents['"]\)/.test(MAIN)
);
check(
'main.js dataDir 校验使用 app.getPath("downloads")',
/app\.getPath\(['"]downloads['"]\)/.test(MAIN)
);
check(
'main.js dataDir 校验使用 app.getPath("desktop")',
/app\.getPath\(['"]desktop['"]\)/.test(MAIN)
);
check(
'main.js dataDir 校验使用 app.getPath("userData")',
/app\.getPath\(['"]userData['"]\)/.test(MAIN)
);
check(
'main.js dataDir 校验使用 app.getPath("appData")',
/app\.getPath\(['"]appData['"]\)/.test(MAIN)
);
check(
'main.js 用 realpathSync 解析后再比对白名单',
/realpathSync/.test(MAIN) && /\.toLowerCase\(\)/.test(MAIN),
'symlink 解析后再做白名单比对,防止间接绕过'
);
check(
'main.js dataDir 校验失败时抛 Error 而非 console.warn',
/throw new Error\([`"]数据目录必须在用户已知目录内/.test(MAIN) ||
/throw new Error\([`"]数据目录/.test(MAIN),
'v4+ 修复:抛错让 IPC rejectUI 走 save-error toast'
);
// ─────────────────────────────────────────────────────────────────────────────
// 安全 S3: loadConfig 过滤 __proto__ 等危险 key
// ─────────────────────────────────────────────────────────────────────────────
section('安全: 原型污染过滤');
check(
'main.js 有 _stripProtoKeys helper',
/_stripProtoKeys/.test(MAIN),
'递归过滤 __proto__ / constructor / prototype 三个 key'
);
check(
'_stripProtoKeys 拒绝 __proto__',
/['"]__proto__['"]/.test(MAIN) || /__proto__/.test(MAIN)
);
check(
'_stripProtoKeys 拒绝 constructor / prototype',
/constructor/.test(MAIN) && /prototype/.test(MAIN)
);
check(
'loadConfig 走 _stripProtoKeys 后再 spread',
/loadConfig[\s\S]{0,300}_stripProtoKeys/.test(MAIN)
);
// ─────────────────────────────────────────────────────────────────────────────
// 错误处理 E1: .bak 自动回退
// ─────────────────────────────────────────────────────────────────────────────
section('错误处理: .bak 自动回退');
check(
'app.js 有 tryReadBak helper',
/tryReadBak/.test(APP),
'loadFromContent 解析失败 / 空内容时尝试 .bak'
);
check(
'app.js bootstrap 在 store.loadFromContent 失败时尝试 .bak',
/tryReadBak[\s\S]{0,500}store\.loadFromContent|store\.loadFromContent[\s\S]{0,200}tryReadBak/.test(APP) ||
/\.bak/.test(APP),
'解析失败 → 读 .bak → 再 loadFromContent'
);
// ─────────────────────────────────────────────────────────────────────────────
// 错误处理 E2: addTask 返回 null 时弹 toast
// ─────────────────────────────────────────────────────────────────────────────
section('错误处理: addTask null 反馈');
check(
'task-list.js 在 store.addTask 返回 null 时弹 toast',
/store\.addTask\([\s\S]{0,200}\bif\s*\(\s*task\s*\)[\s\S]{0,400}\belse\s*\{[\s\S]{0,200}toast/.test(TASK_LIST) ||
/store\.addTask[\s\S]{0,300}\bif\s*\(\s*!\s*task/.test(TASK_LIST),
'添加失败要给用户反馈,不能静默吞掉'
);
// ─────────────────────────────────────────────────────────────────────────────
// 错误处理 E3: parseMarkdown 抛错时 emit load-failed
// ─────────────────────────────────────────────────────────────────────────────
section('错误处理: parseMarkdown 异常捕获');
check(
'task-store.js loadFromContent 对 parseMarkdown 加 try/catch',
/parseMarkdown\(content\)[\s\S]{0,300}catch\s*\(\s*parseErr\s*\)/.test(TASK_STORE) ||
/try\s*\{[\s\S]{0,50}incomingCategories\s*=\s*parseMarkdown/.test(TASK_STORE),
'解析失败要 emit load-failed 让 UI 走 .bak / 冲突解决路径'
);
check(
'task-store.js parseMarkdown 抛错时 emit("load-failed", ...)',
/emit\(['"]load-failed['"]/.test(TASK_STORE)
);
check(
'parseMarkdown 抛错时 Error.code === "PARSE_FAILED"',
/code\s*=\s*['"]PARSE_FAILED['"]/.test(TASK_STORE)
);
// ─────────────────────────────────────────────────────────────────────────────
// 数据完整性 D1+D2: originalCategory 转义 / 反转义
// ─────────────────────────────────────────────────────────────────────────────
section('数据完整性: originalCategory 转义');
check(
'markdown-writer 对 originalCategory 转义 ](避开解析字段截断)',
MARKDOWN_WRITER.includes('/\\]/g'),
'写盘时 ] 必须转义为 \\],否则 parser 在 [原分类:工作] 这类含 ] 名字里截断'
);
check(
'markdown-parser 解析 originalCategory 的正则允许 \\.',
/\\\\\.|\\\\\\./.test(MARKDOWN_PARSER) ||
/\\\\\\./.test(MARKDOWN_PARSER),
'parser 必须接受 \\. 让转义能往返'
);
check(
'markdown-parser 解析后 unescapeOriginalCategory 把 \\] / \\\\ 还原',
/unescapeOriginalCategory/.test(MARKDOWN_PARSER) ||
MARKDOWN_PARSER.includes("replace(/\\\\\\\\\\\\/g"),
'读回时反转义:\\] → ]\\\\ → \\'
);
// ─────────────────────────────────────────────────────────────────────────────
// 数据完整性 D3: 多行任务文本 \\n 保留
// ─────────────────────────────────────────────────────────────────────────────
section('数据完整性: 多行任务文本');
check(
'markdown-writer 写盘时换行转义为 \\n',
/escapeTaskTextSegment/.test(MARKDOWN_WRITER) ||
MARKDOWN_WRITER.includes("split('\\\\n')") ||
MARKDOWN_WRITER.includes('\\\\r\\\\n'),
'内存里 \\n 实际是换行符,写盘要写成 \\n 否则 parser 当成多任务处理'
);
check(
'markdown-parser 读回时把 \\n 还原为换行',
MARKDOWN_PARSER.includes('/\\\\n/g') ||
MARKDOWN_PARSER.includes('replace(/\\\\n/'),
'parser 把 \\n 序列还原成换行符,保证文本往返一致'
);
// ─────────────────────────────────────────────────────────────────────────────
// 性能 P1: SVG 字面量提升
// ─────────────────────────────────────────────────────────────────────────────
section('性能: SVG 字面量提升');
check(
'task-list.js 有 STAR_SVG_FILLED 等模块级 SVG 常量',
/STAR_SVG_FILLED/.test(TASK_LIST) && /STAR_SVG_OUTLINE/.test(TASK_LIST),
'原本是每次 _buildItem 重新构造,提到模块层只生成一次'
);
check(
'task-list.js 有 starSvgFor(active) helper',
/starSvgFor/.test(TASK_LIST)
);
check(
'task-list.js 有 currentSvgFor(active) helper',
/currentSvgFor/.test(TASK_LIST)
);
check(
'task-list.js 没有重复的 inline STAR_SVG 模板字面量',
(TASK_LIST.match(/<svg[^>]*xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g) || []).length <= 8,
'多个 svg 都从常量引用,不应该重复模板字面量'
);
// ─────────────────────────────────────────────────────────────────────────────
// 性能 P2+P3: 拖拽 rAF 节流
// ─────────────────────────────────────────────────────────────────────────────
section('性能: 拖拽 rAF 节流');
check(
'task-list.js pointermove 缓存 pendingMove',
/d\.pendingMove\s*=\s*e/.test(TASK_LIST),
'把最近一次 move 存到 d.pendingMove下一帧才真正 _updateDrag'
);
check(
'task-list.js pointermove 用 rafScheduled 标志',
/d\.rafScheduled\s*=\s*true/.test(TASK_LIST) &&
/requestAnimationFrame/.test(TASK_LIST)
);
check(
'task-list.js 有 _flushPendingDrag 在 pointerup 同步落点',
/_flushPendingDrag/.test(TASK_LIST),
'避免松手瞬间落点还停留在上一帧动画结束位置≤8ms 偏差)'
);
check(
'_finishDrag 在移动前先 _flushPendingDrag',
/_finishDrag\([\s\S]{0,200}_flushPendingDrag/.test(TASK_LIST) ||
/_flushPendingDrag\(d\)/.test(TASK_LIST)
);
// ─────────────────────────────────────────────────────────────────────────────
// 性能 P4: 状态栏缓存
// ─────────────────────────────────────────────────────────────────────────────
section('性能: 状态栏文本相同跳过 DOM 写入');
check(
'app.js setupStatusBar 有 oldText 缓存',
/oldText/.test(APP) || /nextText\s*===\s*oldText/.test(APP)
);
check(
'app.js setupStatusBar 比较 nextText 与 oldText 后才写入',
/nextText\s*===\s*oldText/.test(APP) || /text\s*===\s*oldText/.test(APP)
);
// ─────────────────────────────────────────────────────────────────────────────
// 并发 C1: feedback.js 模块级监听器 AbortController
// ─────────────────────────────────────────────────────────────────────────────
section('并发: feedback.js 模块级监听器 AbortController');
check(
'feedback.js 有 globalListenerAC',
/globalListenerAC/.test(FEEDBACK)
);
check(
'feedback.js 有 globalListenersInstalled 标志',
/globalListenersInstalled/.test(FEEDBACK)
);
check(
'feedback.js 有 installGlobalListeners() 一次性安装',
/installGlobalListeners/.test(FEEDBACK)
);
check(
'feedback.js export disposeFeedbackGlobals',
/export\s+function\s+disposeFeedbackGlobals/.test(FEEDBACK),
'提供显式 dispose 入口给 hot-reload / 测试用'
);
check(
'feedback.js 模块级监听器走 signal 而非裸 addEventListener',
/\{ signal:\s*globalListenerAC\.signal\s*\}/.test(FEEDBACK),
'document.addEventListener(..., {signal}) 让 abort 时一键清理'
);
// ─────────────────────────────────────────────────────────────────────────────
// 并发 C2: bootstrapUnsubs disposeBootstrap
// ─────────────────────────────────────────────────────────────────────────────
section('并发: disposeBootstrap 清理函数');
check(
'app.js export disposeBootstrap',
/export\s+function\s+disposeBootstrap/.test(APP)
);
check(
'disposeBootstrap 取消 bootstrapUnsubs 池中的所有函数',
/disposeBootstrap[\s\S]{0,400}bootstrapUnsubs\.pop/.test(APP) ||
/bootstrapUnsubs[\s\S]{0,400}disposeBootstrap/.test(APP)
);
// ─────────────────────────────────────────────────────────────────────────────
// 卫生 H1: app.js 没有未限定的 console.log保留 console.error/warn
// ─────────────────────────────────────────────────────────────────────────────
section('卫生: 剥离调试日志');
// 检查 app.js 顶层 / bootstrap() 里没有 console.log 残留
// 允许的例外rendererLog(...) 这种被 isDev / syncLog 守卫的 helper 内部。
// 把所有 .test(/console\.log/) 之外、非 helper 函数体内部的 console.log 视为违规。
const helperBodies = APP.match(/function\s+\w+\s*\([^)]*\)\s*\{[\s\S]*?console\.log[\s\S]*?\n\}/g) || [];
const helperLines = new Set();
for (const body of helperBodies) {
for (const line of body.split('\n')) helperLines.add(line.trim());
}
const allLogs = APP.split('\n').filter(l => /console\.log/.test(l));
const strayLogs = allLogs.filter(l => !helperLines.has(l.trim()));
check(
'app.js 顶层 / bootstrap 里没有 console.log 残留(仅允许 dev-守卫的 helper 内)',
strayLogs.length === 0,
`残留位置:${strayLogs.map(l => l.trim()).slice(0, 3).join(' | ')}`
);
// ─────────────────────────────────────────────────────────────────────────────
printSummary('check-third-pass-audit');