Skip to content

Commit a1be346

Browse files
committed
fix(sovereignty): 审查修复——恢复表白名单扩展(world_snapshots/imports)+ 校验加固 + BOM/大小防御 + 超限提示
1 parent 216d9c3 commit a1be346

7 files changed

Lines changed: 178 additions & 11 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* 可导入表白名单(零依赖纯逻辑,供 migration.ts 与测试共用)
3+
* Import whitelist (zero-dependency; shared by migration.ts and tests)
4+
*
5+
* @ai-context: importTable 的合法表 = IndexedDB 迁移白名单(TABLE_MAPPING
6+
* 派生,migration.ts 持有)+ 世界恢复扩展(world_snapshots/imports,
7+
* schema.ts 新增表)。扩展表单独声明,避免污染迁移流程;isImportableTable
8+
* 供世界导出白名单(worldExport.ts WORLD_TABLE_WHITELIST)与迁移白名单
9+
* 做一致性校验——导出/恢复信任动作的表名必须可被 importTable 接受。
10+
*
11+
* @ai-context: Importable tables = migration whitelist + restore-only
12+
* extras. Kept separate so the migration flow is untouched; tests assert
13+
* every exportable table is importable to prevent whitelist drift.
14+
*/
15+
16+
/** 世界恢复扩展白名单(schema.ts 新增表,非 IndexedDB 迁移表) */
17+
export const RESTORE_EXTRA_TABLES = ['world_snapshots', 'imports'] as const;
18+
export type RestoreExtraTable = (typeof RESTORE_EXTRA_TABLES)[number];
19+
20+
/**
21+
* 判断表名是否可被 importTable 导入
22+
* @param table - SQLite 表名
23+
* @param migrationTables - 迁移白名单(TABLE_MAPPING 派生,由调用方传入)
24+
*/
25+
export function isImportableTable(table: string, migrationTables: readonly string[]): boolean {
26+
return migrationTables.includes(table) || (RESTORE_EXTRA_TABLES as readonly string[]).includes(table);
27+
}

‎client/electron/db/migration.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import type Database from 'better-sqlite3';
1111
import { getConnection } from './sqliteService.js';
12+
import { isImportableTable } from './importWhitelist.js';
1213
import { logger } from '../logger.js';
1314

1415
// ================================================================
@@ -57,8 +58,8 @@ const TABLE_MAPPING: Array<{ dexie: string; sqlite: string }> = [
5758
{ dexie: 'crdtChanges', sqlite: 'crdt_changes' },
5859
];
5960

60-
/** 合法的 SQLite 表名集合 */
61-
const VALID_SQLITE_TABLES = new Set(TABLE_MAPPING.map((m) => m.sqlite));
61+
/** 合法 SQLite 表名列表(迁移白名单;与 importWhitelist 扩展表共同构成可导入集) */
62+
const MIGRATION_TABLE_LIST = TABLE_MAPPING.map((m) => m.sqlite);
6263

6364
/** camelCase → snake_case */
6465
function toSnake(s: string): string {
@@ -91,7 +92,7 @@ export function needsMigration(db: Database.Database): boolean {
9192
* @returns 成功插入的行数
9293
*/
9394
export function importTable(sqliteTable: string, rows: Record<string, unknown>[]): number {
94-
if (!VALID_SQLITE_TABLES.has(sqliteTable)) {
95+
if (!isImportableTable(sqliteTable, MIGRATION_TABLE_LIST)) {
9596
throw new Error(`[Migration] Table "${sqliteTable}" is not in the migration whitelist`);
9697
}
9798

‎client/electron/sovereigntyHandlers.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* then rebuilds the FTS index.
1818
*/
1919
import { dialog } from 'electron';
20-
import { writeFile, readFile } from 'fs/promises';
20+
import { writeFile, readFile, stat } from 'fs/promises';
2121
import { safeHandle } from './ipcUtils.js';
2222
import { logger } from './logger.js';
2323
import { getConnection } from './db/sqliteService.js';
@@ -28,13 +28,16 @@ import {
2828
buildWorldExport,
2929
validateWorldImport,
3030
WORLD_TABLE_WHITELIST,
31+
WORLD_EXPORT_MAX_ROWS,
3132
type WorldTableBundle,
3233
} from '../src/features/sovereignty/lib/worldExport.js';
3334

3435
/** 快照单行 id(与 useWorldSnapshotSync 的 SNAPSHOT_ID 对齐) / Snapshot row id */
3536
const SNAPSHOT_ID = 'latest';
3637
/** 入籍记录导出上限(叙述层防超大响应;恢复层 tables.imports 不受限) */
3738
const MAX_RECORDS = 5000;
39+
/** 恢复文件大小防御上限(防超大文件读入内存拖垮 JSON.parse) / Max import file bytes */
40+
const MAX_IMPORT_BYTES = 256 * 1024 * 1024;
3841

3942
/** 恢复层表白名单(父表在前,满足外键约束;与 worldExport 白名单同源) */
4043
const EXPORT_TABLES = WORLD_TABLE_WHITELIST;
@@ -94,8 +97,13 @@ export function registerSovereigntyHandlers(): void {
9497
await writeFile(result.filePath, JSON.stringify(bundle, null, 2), 'utf-8');
9598
const rowCount = tables.reduce((s, t) => s + t.rows.length, 0);
9699
const nodeCount = Array.isArray(bundle.graph.nodes) ? bundle.graph.nodes.length : 0;
100+
// 对称性提示:导出不受限(备份语义),但超过恢复上限时提醒用户
101+
const overLimit = rowCount > WORLD_EXPORT_MAX_ROWS;
102+
if (overLimit) {
103+
logger.warn(`[Sovereignty] Export exceeds restore limit (${rowCount} > ${WORLD_EXPORT_MAX_ROWS} rows); the file will not be restorable`);
104+
}
97105
logger.info(`[Sovereignty] World exported to ${result.filePath} (${rowCount} rows, ${nodeCount} nodes)`);
98-
return { success: true, canceled: false, path: result.filePath };
106+
return { success: true, canceled: false, path: result.filePath, overLimit };
99107
} catch (err) {
100108
const msg = err instanceof Error ? err.message : String(err);
101109
logger.error('[Sovereignty] Export failed', err);
@@ -121,7 +129,14 @@ export function registerSovereigntyHandlers(): void {
121129
}
122130

123131
try {
124-
const text = await readFile(result.filePaths[0], 'utf-8');
132+
// 大小防御:超大文件先拒绝,避免全量读入内存 + JSON.parse 内存峰值
133+
const fileStat = await stat(result.filePaths[0]);
134+
if (fileStat.size > MAX_IMPORT_BYTES) {
135+
return { success: false, error: '文件过大(超过 256MB),无法恢复' };
136+
}
137+
const rawText = await readFile(result.filePaths[0], 'utf-8');
138+
// 兼容 Windows 记事本保存的 UTF-8 BOM
139+
const text = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText;
125140
let parsed: unknown;
126141
try {
127142
parsed = JSON.parse(text);
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* importWhitelist 单元测试(阶段 D 致命修复:恢复表可导入性)
3+
* Import whitelist tests (stage-D fix: restore tables must be importable)
4+
*
5+
* @ai-context: 回归覆盖阶段 D 致命 bug——importTable 白名单曾不含
6+
* world_snapshots/imports,世界恢复必然失败。本测试验证:
7+
* ① 世界导出白名单(WORLD_TABLE_WHITELIST)全部表可被 importTable 接受
8+
* (防双白名单漂移);② 迁移白名单表仍可导入(未破坏迁移流程);
9+
* ③ 白名单外表拒绝。零依赖——不加载 better-sqlite3(Electron ABI),
10+
* 纯逻辑验证 isImportableTable。
11+
*
12+
* @ai-context: Regression for the stage-D bug where world_snapshots and
13+
* imports were rejected by importTable. Asserts every exportable table
14+
* is importable (no whitelist drift), migration tables still work, and
15+
* unknown tables are rejected.
16+
*/
17+
import { describe, it, expect } from 'vitest';
18+
import {
19+
isImportableTable,
20+
RESTORE_EXTRA_TABLES,
21+
} from '../../../../electron/db/importWhitelist.js';
22+
import { WORLD_TABLE_WHITELIST } from './worldExport';
23+
24+
/** 迁移白名单样例(migration.ts TABLE_MAPPING 子集,覆盖代表性表) */
25+
const MIGRATION_TABLES = [
26+
'note_folders',
27+
'notes',
28+
'flashcard_decks',
29+
'flashcards',
30+
'flashcard_reviews',
31+
'feynman_notes',
32+
'feynman_summaries',
33+
'feynman_weak_points',
34+
'app_settings',
35+
'pomodoro_sessions',
36+
] as const;
37+
38+
describe('isImportableTable(可导入表白名单)', () => {
39+
it('世界导出白名单全部表均可导入(防双白名单漂移)', () => {
40+
// Arrange & Act & Assert
41+
for (const table of WORLD_TABLE_WHITELIST) {
42+
expect(isImportableTable(table, MIGRATION_TABLES), `表 ${table} 应可导入`).toBe(true);
43+
}
44+
});
45+
46+
it('世界恢复扩展表含 world_snapshots 与 imports(阶段 D 致命修复回归)', () => {
47+
// Arrange & Act & Assert
48+
expect(RESTORE_EXTRA_TABLES).toContain('world_snapshots');
49+
expect(RESTORE_EXTRA_TABLES).toContain('imports');
50+
expect(isImportableTable('world_snapshots', MIGRATION_TABLES)).toBe(true);
51+
expect(isImportableTable('imports', MIGRATION_TABLES)).toBe(true);
52+
});
53+
54+
it('迁移白名单表仍可导入(扩展不破坏迁移流程)', () => {
55+
// Arrange & Act & Assert
56+
for (const table of MIGRATION_TABLES) {
57+
expect(isImportableTable(table, MIGRATION_TABLES), `迁移表 ${table} 应可导入`).toBe(true);
58+
}
59+
});
60+
61+
it('白名单外表拒绝(evil_table / 空串)', () => {
62+
// Arrange & Act & Assert
63+
expect(isImportableTable('evil_table', MIGRATION_TABLES)).toBe(false);
64+
expect(isImportableTable('', MIGRATION_TABLES)).toBe(false);
65+
expect(isImportableTable('notes', [])).toBe(false); // 迁移列表为空时仅扩展表可导入
66+
expect(isImportableTable('world_snapshots', [])).toBe(true);
67+
});
68+
});

‎client/src/features/sovereignty/lib/worldExport.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,40 @@ describe('validateWorldImport(恢复前校验)', () => {
197197
expect(result.ok).toBe(false);
198198
if (!result.ok) expect(result.error).toContain('恢复数据量超出上限');
199199
});
200+
201+
it('拒绝 rows 含非对象行(防 Object.keys(null) 崩溃与空列 SQL)', () => {
202+
// Arrange
203+
const raw = makeBundle({ tables: [{ table: 'notes', rows: [{ id: 'n1' }, null, 'raw', 42] }] });
204+
205+
// Act
206+
const result = validateWorldImport(raw);
207+
208+
// Assert
209+
expect(result.ok).toBe(false);
210+
if (!result.ok) expect(result.error).toContain('含非对象行');
211+
});
212+
213+
it('拒绝入籍记录超限(100001 条 > 100000)', () => {
214+
// Arrange
215+
const records = Array.from({ length: 100_001 }, (_, i) => ({ id: `r${i}` }));
216+
const raw = makeBundle({ settlingRecords: records });
217+
218+
// Act
219+
const result = validateWorldImport(raw);
220+
221+
// Assert
222+
expect(result.ok).toBe(false);
223+
if (!result.ok) expect(result.error).toContain('入籍记录超出上限');
224+
});
225+
226+
it('接受空世界(零记录 + 空恢复层):迁移前或清库后仍可恢复', () => {
227+
// Arrange
228+
const raw = makeBundle({ settlingRecords: [], tables: [] });
229+
230+
// Act
231+
const result = validateWorldImport(raw);
232+
233+
// Assert
234+
expect(result.ok).toBe(true);
235+
});
200236
});

‎client/src/features/sovereignty/lib/worldExport.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const WORLD_EXPORT_FORMAT_VERSION = 1;
2323
export const WORLD_EXPORT_MAX_NODES = 5000;
2424
/** 恢复层总行数上限 / Max total restore rows */
2525
export const WORLD_EXPORT_MAX_ROWS = 100_000;
26+
/** 叙述层入籍记录上限(防超大文件拖垮解析,与行上限对称) / Max settling records */
27+
export const WORLD_EXPORT_MAX_RECORDS = 100_000;
2628

2729
/** 恢复层表名白名单(与 schema.ts / importTable 白名单对齐) / Restore whitelist */
2830
export const WORLD_TABLE_WHITELIST = [
@@ -157,6 +159,12 @@ export function validateWorldImport(raw: unknown): WorldImportResult {
157159
if (!Array.isArray(raw.settlingRecords)) {
158160
return { ok: false, error: '入籍记录格式不正确' };
159161
}
162+
if (raw.settlingRecords.length > WORLD_EXPORT_MAX_RECORDS) {
163+
return {
164+
ok: false,
165+
error: `入籍记录超出上限(${raw.settlingRecords.length} 条 > ${WORLD_EXPORT_MAX_RECORDS})`,
166+
};
167+
}
160168

161169
if (!Array.isArray(raw.tables)) {
162170
return { ok: false, error: '恢复数据缺失(tables 不是数组)' };
@@ -172,6 +180,12 @@ export function validateWorldImport(raw: unknown): WorldImportResult {
172180
if (!Array.isArray(item.rows)) {
173181
return { ok: false, error: `恢复数据格式不正确(${item.table}.rows 不是数组)` };
174182
}
183+
for (const row of item.rows) {
184+
// 行必须是对象:防 Object.keys(null) 崩溃与空列 SQL 错误,且保证列名可推断
185+
if (!isPlainObject(row)) {
186+
return { ok: false, error: `恢复数据格式不正确(${item.table}.rows 含非对象行)` };
187+
}
188+
}
175189
totalRows += item.rows.length;
176190
}
177191
if (totalRows > WORLD_EXPORT_MAX_ROWS) {

‎client/src/pages/settings/WorldSovereigntySection.tsx‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ interface ExportResult {
2626
success: boolean;
2727
canceled?: boolean;
2828
path?: string | null;
29+
/** 导出行数超过恢复上限(文件仍可作备份,但无法恢复) */
30+
overLimit?: boolean;
2931
error?: string;
3032
}
3133

@@ -52,11 +54,15 @@ export function WorldSovereigntySection() {
5254
if (res.canceled) return; // 用户取消保存框:静默
5355
if (res.success) {
5456
soundPlayer.play('data_export');
55-
toast({
56-
type: 'success',
57-
message: res.path ? `世界之书已导出:${res.path}` : '世界之书已导出',
58-
silent: true,
59-
});
57+
if (res.overLimit) {
58+
toast({ type: 'error', message: '导出完成,但数据量超过恢复上限(10 万行)——此文件可作备份,无法用于恢复' });
59+
} else {
60+
toast({
61+
type: 'success',
62+
message: res.path ? `世界之书已导出:${res.path}` : '世界之书已导出',
63+
silent: true,
64+
});
65+
}
6066
} else {
6167
toast({ type: 'error', message: res.error ?? '世界导出失败,请重试' });
6268
}

0 commit comments

Comments
 (0)