fix: CI Node 18/20 下 aria-crypto 失败 — SubtleCrypto 跨 realm ArrayBuffer 兼容
- crypto.ts encryptPage/decryptPage 改传 TypedArray 视图(ArrayBuffer.isView 检查跨 realm 可靠)
- jest.setup.js structuredClone polyfill 用跨 realm toString 标签检查
(修复 fake-indexeddb 存储 ArrayBuffer 被 JSON 破坏成 {} 的问题)
- 新增 SSTable 加密真实路径测试(加密落盘→重载解密, 8 个测试)
- aria/v025 固定 db 名改随机(fake-indexeddb 真实持久化后防表残留冲突)
- Node 18/20/22/24 全矩阵 837 测试通过
This commit is contained in:
Vendored
+4
-2
@@ -4403,13 +4403,15 @@ class CryptoManager {
|
|||||||
if (!this.cryptoKey)
|
if (!this.cryptoKey)
|
||||||
throw new Error('Crypto not initialized');
|
throw new Error('Crypto not initialized');
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||||
const ciphertext = await crypto.subtle.encrypt({ name: ALGO, iv }, this.cryptoKey, data);
|
// 传 TypedArray 视图而非裸 ArrayBuffer:SubtleCrypto 通过 ArrayBuffer.isView 检查,
|
||||||
|
// 对跨 realm / 跨 vm 环境的 ArrayBuffer 兼容(Node 18/20 的 webcrypto 对裸 ArrayBuffer 检查严格)
|
||||||
|
const ciphertext = await crypto.subtle.encrypt({ name: ALGO, iv }, this.cryptoKey, new Uint8Array(data));
|
||||||
return { iv: iv, data: ciphertext };
|
return { iv: iv, data: ciphertext };
|
||||||
}
|
}
|
||||||
async decryptPage(iv, data) {
|
async decryptPage(iv, data) {
|
||||||
if (!this.cryptoKey)
|
if (!this.cryptoKey)
|
||||||
throw new Error('Crypto not initialized');
|
throw new Error('Crypto not initialized');
|
||||||
return crypto.subtle.decrypt({ name: ALGO, iv }, this.cryptoKey, data);
|
return crypto.subtle.decrypt({ name: ALGO, iv }, this.cryptoKey, new Uint8Array(data));
|
||||||
}
|
}
|
||||||
close() {
|
close() {
|
||||||
this.cryptoKey = null;
|
this.cryptoKey = null;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
-2
@@ -4399,13 +4399,15 @@ class CryptoManager {
|
|||||||
if (!this.cryptoKey)
|
if (!this.cryptoKey)
|
||||||
throw new Error('Crypto not initialized');
|
throw new Error('Crypto not initialized');
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||||
const ciphertext = await crypto.subtle.encrypt({ name: ALGO, iv }, this.cryptoKey, data);
|
// 传 TypedArray 视图而非裸 ArrayBuffer:SubtleCrypto 通过 ArrayBuffer.isView 检查,
|
||||||
|
// 对跨 realm / 跨 vm 环境的 ArrayBuffer 兼容(Node 18/20 的 webcrypto 对裸 ArrayBuffer 检查严格)
|
||||||
|
const ciphertext = await crypto.subtle.encrypt({ name: ALGO, iv }, this.cryptoKey, new Uint8Array(data));
|
||||||
return { iv: iv, data: ciphertext };
|
return { iv: iv, data: ciphertext };
|
||||||
}
|
}
|
||||||
async decryptPage(iv, data) {
|
async decryptPage(iv, data) {
|
||||||
if (!this.cryptoKey)
|
if (!this.cryptoKey)
|
||||||
throw new Error('Crypto not initialized');
|
throw new Error('Crypto not initialized');
|
||||||
return crypto.subtle.decrypt({ name: ALGO, iv }, this.cryptoKey, data);
|
return crypto.subtle.decrypt({ name: ALGO, iv }, this.cryptoKey, new Uint8Array(data));
|
||||||
}
|
}
|
||||||
close() {
|
close() {
|
||||||
this.cryptoKey = null;
|
this.cryptoKey = null;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
-2
@@ -4405,13 +4405,15 @@
|
|||||||
if (!this.cryptoKey)
|
if (!this.cryptoKey)
|
||||||
throw new Error('Crypto not initialized');
|
throw new Error('Crypto not initialized');
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||||
const ciphertext = await crypto.subtle.encrypt({ name: ALGO, iv }, this.cryptoKey, data);
|
// 传 TypedArray 视图而非裸 ArrayBuffer:SubtleCrypto 通过 ArrayBuffer.isView 检查,
|
||||||
|
// 对跨 realm / 跨 vm 环境的 ArrayBuffer 兼容(Node 18/20 的 webcrypto 对裸 ArrayBuffer 检查严格)
|
||||||
|
const ciphertext = await crypto.subtle.encrypt({ name: ALGO, iv }, this.cryptoKey, new Uint8Array(data));
|
||||||
return { iv: iv, data: ciphertext };
|
return { iv: iv, data: ciphertext };
|
||||||
}
|
}
|
||||||
async decryptPage(iv, data) {
|
async decryptPage(iv, data) {
|
||||||
if (!this.cryptoKey)
|
if (!this.cryptoKey)
|
||||||
throw new Error('Crypto not initialized');
|
throw new Error('Crypto not initialized');
|
||||||
return crypto.subtle.decrypt({ name: ALGO, iv }, this.cryptoKey, data);
|
return crypto.subtle.decrypt({ name: ALGO, iv }, this.cryptoKey, new Uint8Array(data));
|
||||||
}
|
}
|
||||||
close() {
|
close() {
|
||||||
this.cryptoKey = null;
|
this.cryptoKey = null;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+10
-1
@@ -1,6 +1,15 @@
|
|||||||
// jest setup: polyfill structuredClone for fake-indexeddb
|
// jest setup: polyfill structuredClone for fake-indexeddb
|
||||||
if (typeof globalThis.structuredClone !== 'function') {
|
if (typeof globalThis.structuredClone !== 'function') {
|
||||||
globalThis.structuredClone = (obj) => JSON.parse(JSON.stringify(obj));
|
// 注意:不能用 JSON 序列化 —— ArrayBuffer/TypedArray 会被破坏成 {},
|
||||||
|
// fake-indexeddb 存储 AriaEngine 二进制页面(4KB SSTable/WAL)依赖正确的克隆。
|
||||||
|
// 且不能依赖 instanceof(TextEncoder 产生 node realm 的 ArrayBuffer,
|
||||||
|
// 与 jsdom realm 的构造函数不匹配),需用跨 realm 的 toString 标签检查。
|
||||||
|
globalThis.structuredClone = (obj) => {
|
||||||
|
const tag = Object.prototype.toString.call(obj);
|
||||||
|
if (tag === '[object ArrayBuffer]') return obj.slice(0);
|
||||||
|
if (ArrayBuffer.isView(obj)) return new obj.constructor(obj);
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// polyfill TextEncoder/TextDecoder for jsdom environment
|
// polyfill TextEncoder/TextDecoder for jsdom environment
|
||||||
|
|||||||
@@ -36,13 +36,15 @@ export class CryptoManager {
|
|||||||
async encryptPage(data: ArrayBuffer): Promise<{ iv: Uint8Array; data: ArrayBuffer }> {
|
async encryptPage(data: ArrayBuffer): Promise<{ iv: Uint8Array; data: ArrayBuffer }> {
|
||||||
if (!this.cryptoKey) throw new Error('Crypto not initialized');
|
if (!this.cryptoKey) throw new Error('Crypto not initialized');
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) as any;
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) as any;
|
||||||
const ciphertext = await crypto.subtle.encrypt({ name: ALGO, iv } as any, this.cryptoKey, data);
|
// 传 TypedArray 视图而非裸 ArrayBuffer:SubtleCrypto 通过 ArrayBuffer.isView 检查,
|
||||||
|
// 对跨 realm / 跨 vm 环境的 ArrayBuffer 兼容(Node 18/20 的 webcrypto 对裸 ArrayBuffer 检查严格)
|
||||||
|
const ciphertext = await crypto.subtle.encrypt({ name: ALGO, iv } as any, this.cryptoKey, new Uint8Array(data));
|
||||||
return { iv: iv as Uint8Array, data: ciphertext };
|
return { iv: iv as Uint8Array, data: ciphertext };
|
||||||
}
|
}
|
||||||
|
|
||||||
async decryptPage(iv: Uint8Array, data: ArrayBuffer): Promise<ArrayBuffer> {
|
async decryptPage(iv: Uint8Array, data: ArrayBuffer): Promise<ArrayBuffer> {
|
||||||
if (!this.cryptoKey) throw new Error('Crypto not initialized');
|
if (!this.cryptoKey) throw new Error('Crypto not initialized');
|
||||||
return crypto.subtle.decrypt({ name: ALGO, iv } as any, this.cryptoKey, data);
|
return crypto.subtle.decrypt({ name: ALGO, iv } as any, this.cryptoKey, new Uint8Array(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*
|
*
|
||||||
* v0.2.6 补强:此前仅验证实例化,现在验证真实的加解密往返一致性。
|
* v0.2.6 补强:此前仅验证实例化,现在验证真实的加解密往返一致性。
|
||||||
*/
|
*/
|
||||||
|
import 'fake-indexeddb/auto';
|
||||||
import {
|
import {
|
||||||
CryptoManager,
|
CryptoManager,
|
||||||
initCrypto,
|
initCrypto,
|
||||||
@@ -109,3 +110,48 @@ describe('AriaEngine — CryptoManager', () => {
|
|||||||
cm.close();
|
cm.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 真实存储路径:SSTable 加密 → 持久化 → 重载解密(v0.3.2 CI 修复)
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('AriaEngine — SSTable 加密存储真实路径', () => {
|
||||||
|
test('加密落盘后重载可完整解密', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const { AriaEngine } = require('../../src/engine/aria/index');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const { createSchema } = require('../../src/table/schema');
|
||||||
|
const dbName = `enc-store-${Date.now()}`;
|
||||||
|
|
||||||
|
await initCrypto('engine-store-password');
|
||||||
|
|
||||||
|
const engine = new AriaEngine({
|
||||||
|
storageBackend: 'indexeddb',
|
||||||
|
walSyncMode: 'none',
|
||||||
|
} as any);
|
||||||
|
await engine.open(dbName, 1);
|
||||||
|
await engine.createTable(createSchema('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
v: { type: 'number' },
|
||||||
|
}));
|
||||||
|
await engine.insert('t', [{ id: '1', v: 42 }, { id: '2', v: 99 }]);
|
||||||
|
// 强制刷盘:SSTable 经加密保存(encryptPage 真实路径)
|
||||||
|
await (engine as any).lsm.flush();
|
||||||
|
await engine.close();
|
||||||
|
|
||||||
|
// 重载:SSTable 解密恢复
|
||||||
|
const engine2 = new AriaEngine({
|
||||||
|
storageBackend: 'indexeddb',
|
||||||
|
walSyncMode: 'none',
|
||||||
|
} as any);
|
||||||
|
await engine2.open(dbName, 1);
|
||||||
|
const rows = await engine2.find('t', { table: 't' });
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
const byId = Object.fromEntries(rows.map((r: any) => [r.id, r.v]));
|
||||||
|
expect(byId['1']).toBe(42);
|
||||||
|
expect(byId['2']).toBe(99);
|
||||||
|
await engine2.close();
|
||||||
|
|
||||||
|
closeCrypto();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ describe('MetonaSqlark with mode=aria (Memory)', () => {
|
|||||||
let db: MetonaSqlark;
|
let db: MetonaSqlark;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
db = new MetonaSqlark({ name: 'ms-aria-test', mode: 'aria', diskEngine: 'indexeddb' });
|
db = new MetonaSqlark({ name: `ms-aria-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'aria', diskEngine: 'indexeddb' });
|
||||||
await db.init();
|
await db.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ describe('[v0.2.5] P0-1: 版本号统一', () => {
|
|||||||
|
|
||||||
describe('[v0.2.5] P0-2: AriaEngine OPFS 后端映射', () => {
|
describe('[v0.2.5] P0-2: AriaEngine OPFS 后端映射', () => {
|
||||||
test('mode=aria + diskEngine=opfs 时应使用 opfs 后端', () => {
|
test('mode=aria + diskEngine=opfs 时应使用 opfs 后端', () => {
|
||||||
const db = new MetonaSqlark({ name: 'test-opfs-map', mode: 'aria', diskEngine: 'opfs' });
|
const db = new MetonaSqlark({ name: `test-opfs-map-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'aria', diskEngine: 'opfs' });
|
||||||
// 不实际 open(需要浏览器环境),只验证 createEngine 逻辑
|
// 不实际 open(需要浏览器环境),只验证 createEngine 逻辑
|
||||||
// 通过 getEngine 在 init 后检查
|
// 通过 getEngine 在 init 后检查
|
||||||
expect(db).toBeDefined();
|
expect(db).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mode=aria + diskEngine=indexeddb 时应使用 indexeddb 后端', () => {
|
test('mode=aria + diskEngine=indexeddb 时应使用 indexeddb 后端', () => {
|
||||||
const db = new MetonaSqlark({ name: 'test-idb-map', mode: 'aria', diskEngine: 'indexeddb' });
|
const db = new MetonaSqlark({ name: `test-idb-map-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'aria', diskEngine: 'indexeddb' });
|
||||||
expect(db).toBeDefined();
|
expect(db).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user