Files
Todo-List/scripts/check-third-pass-audit.mjs
2026-09-12 13:55:57 +08:00

320 lines
17 KiB
JavaScript
Raw Permalink 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.
// 第三轮全面审计回归:覆盖第二、三轮的所有修复点。
//
// 涵盖:
// 安全 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');