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

174
scripts/check-settings.mjs Normal file
View File

@@ -0,0 +1,174 @@
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { installApiStub } from './_lib/api-stub.mjs';
// 设置存储的回归测试:覆盖 update() 和 load() 的输入校验路径。
//
// 触发 bug 的两种姿势,都靠「脏值穿过校验、污染内存 + 配置文件」来静默损坏用户设置:
//
// 1. update({ theme: null })
// 旧实现只校验「值 !== 当前值」和「key 在 DEFAULT_SETTINGS 中」,于是 theme
// 被设成 null。resolveTheme() 能兜底返回 'dark',但 this._settings.theme
// 仍是 null下一次 setSortBy 等比较触发意外分支;更糟的是下次保存会把脏
// theme: null 写入 config.json —— 旧用户的设置从此永久损坏。
//
// 2. 用户手工编辑 config.json写成 `theme: null` 或 `fontSize: "huge"`
// 加载端只按 key 字段过滤,不校验值类型 / 是否在枚举内 —— 同样的污染路径,
// 只是触发位置从 update 换到了 load。
//
// 这份脚本直接构造 SettingsStore 实例 + 桩 api逐项验证上述姿势都被拦截。
// 跑法node scripts/check-settings.mjs
// 同 check-importsSettingsStore 的模块作用域会注册事件回调,但仅用到
// 内存对象没有副作用。installApiStub() 默认就是空实现。
installApiStub();
const { SettingsStore } = await import('../src/settings-store.js');
// ============================================
// update() 输入校验
// ============================================
console.log('[1] update() 拒绝非法值');
{
const s = new SettingsStore();
await s.update({ theme: null });
if (s.get('theme') === 'dark') ok('update({theme: null}) 被忽略theme 保持 dark');
else bad('theme 被 null 污染', `当前 ${JSON.stringify(s.get('theme'))}`);
}
{
const s = new SettingsStore();
await s.update({ theme: undefined });
if (s.get('theme') === 'dark') ok('update({theme: undefined}) 被忽略');
else bad('theme 被 undefined 污染', `当前 ${JSON.stringify(s.get('theme'))}`);
}
{
const s = new SettingsStore();
await s.update({ filter: 42 });
if (s.get('filter') === 'all') ok('update({filter: 42}) 数字被拒绝');
else bad('filter 被数字污染', `当前 ${JSON.stringify(s.get('filter'))}`);
}
{
const s = new SettingsStore();
await s.update({ fontSize: 'huge' });
if (s.get('fontSize') === 'medium') ok('update({fontSize: "huge"}) 枚举外字符串被拒绝');
else bad('fontSize 被非法字符串污染', `当前 ${JSON.stringify(s.get('fontSize'))}`);
}
{
const s = new SettingsStore();
await s.update({ sortBy: { evil: true } });
if (s.get('sortBy') === 'manual') ok('update({sortBy: object}) 对象被拒绝');
else bad('sortBy 被对象污染', `当前 ${JSON.stringify(s.get('sortBy'))}`);
}
{
const s = new SettingsStore();
await s.update({ __proto__: { theme: 'light' } });
// 原型污染__proto__ 不是 own propertyObject.entries 不该枚举它
if (s.get('theme') === 'dark') ok('update({__proto__}) 原型污染被拒绝');
else bad('theme 被原型污染', `当前 ${JSON.stringify(s.get('theme'))}`);
}
{
const s = new SettingsStore();
await s.update({ unknownField: 'value' });
if (s.get('unknownField') === undefined) ok('update({unknownField}) 未知字段被忽略');
else bad('未知字段被写入', `当前 ${JSON.stringify(s.get('unknownField'))}`);
}
// dataDir 单独测null 是合法语义(恢复默认),字符串也合法
{
const s = new SettingsStore();
await s.update({ dataDir: null });
if (s.get('dataDir') === null) ok('update({dataDir: null}) 保留 null恢复默认语义');
else bad('dataDir: null 被错误拒绝', `当前 ${JSON.stringify(s.get('dataDir'))}`);
}
{
const s = new SettingsStore();
await s.update({ dataDir: 'D:/Tasks' });
if (s.get('dataDir') === 'D:/Tasks') ok('update({dataDir: "D:/Tasks"}) 字符串路径生效');
else bad('dataDir 字符串路径未生效', `当前 ${JSON.stringify(s.get('dataDir'))}`);
}
{
const s = new SettingsStore();
await s.update({ dataDir: 123 });
if (s.get('dataDir') === null) ok('update({dataDir: 123}) 数字被拒绝(保持 null');
else bad('dataDir 数字污染', `当前 ${JSON.stringify(s.get('dataDir'))}`);
}
// 合法值的 happy path
{
const s = new SettingsStore();
const result = await s.update({ theme: 'light', fontSize: 'large' });
if (s.get('theme') === 'light' && s.get('fontSize') === 'large' && result === true) {
ok('update({theme: "light", fontSize: "large"}) 合法值生效并返回 true');
} else {
bad('合法值未生效', JSON.stringify({ theme: s.get('theme'), fontSize: s.get('fontSize'), result }));
}
}
// ============================================
// load() 输入校验 —— 模拟配置文件被外部改成脏值
// ============================================
console.log('\n[2] load() 从脏配置恢复默认值');
{
const s = new SettingsStore();
s._settings = {}; // 重置,确保 load 是从主进程取数
// 临时替换 api 返回脏配置
const orig = globalThis.window.api.getSettings;
globalThis.window.api.getSettings = async () => ({
theme: null,
filter: 42,
sortBy: 'random',
fontSize: 'huge',
dataDir: 'D:/Real' // 合法值,应保留
});
try {
await s.load();
const cur = s.all;
if (cur.theme === 'dark' &&
cur.filter === 'all' &&
cur.sortBy === 'manual' &&
cur.fontSize === 'medium' &&
cur.dataDir === 'D:/Real') {
ok('脏 theme/filter/sortBy/fontSize 全部回退到默认值;合法 dataDir 保留');
} else {
bad('load 污染了设置', JSON.stringify(cur));
}
} finally {
globalThis.window.api.getSettings = orig;
}
}
{
const s = new SettingsStore();
const orig = globalThis.window.api.getSettings;
globalThis.window.api.getSettings = async () => ({
theme: 'light',
filter: 'pending', // 老 enum 值load 端应迁移到 'current'
extraGarbage: 'ignored',
__proto__: { theme: 'dark' } // 原型污染load 端同样要抗
});
try {
await s.load();
const cur = s.all;
if (cur.theme === 'light' && cur.filter === 'current' && cur.extraGarbage === undefined) {
ok('合法字段生效;老 enumpending→current自动迁移未知字段忽略原型污染无效');
} else {
bad('load 表现异常', JSON.stringify(cur));
}
} finally {
globalThis.window.api.getSettings = orig;
}
}
// ============================================
console.log(`\n${'─'.repeat(48)}`);
console.log(`通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);