c3c0a8b17d
新增 secret_get(ref) host function:讀 CF Workers per-script Secrets 的值, host 端實作 = env[ref] 動態字串索引(T1.5 spike ② 已證可行,零網路呼叫)。 - ArcrunHostEnv 加 index signature 支援任意 secret_ref 動態取值 - WasiHostFunctions 加 secret_get,u6u WASI imports 比照 kv_get 同款 pointer/memory-write 機制 wiring - 安全邊界:只允許 CRED_ 前綴(拒絕讀 ENCRYPTION_KEY/CF_SECRETS_API_TOKEN 等非 credential 機密),比照既有 routedKvGet 前綴檢查精神 驗證:tsc --noEmit exit 0;vitest 18/18 通過(新增 5 案例)。 撞牆記錄:JSPI Suspending 物件不可在單元測試直接呼叫(既有架構環境限制, kv_get 同樣受影響),已移除不可行的 2 個測試並記錄於測試檔註解。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018D6QoC5waFkcjc2N7csJBB
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
/**
|
||
* WASI shim 單元測試
|
||
* Task 2.2 — Requirements: 3.1, 3.3
|
||
*/
|
||
|
||
import { describe, it, expect } from 'vitest';
|
||
import { createWasiShim, createArcrunHostFunctions, type ArcrunHostEnv } from '../src/lib/wasi-shim';
|
||
|
||
// 建立一個最小的 fake WebAssembly.Memory(用 ArrayBuffer 模擬)
|
||
function makeFakeMemory(size = 65536): WebAssembly.Memory {
|
||
// 用真實的 WebAssembly.Memory(Vitest 環境支援)
|
||
return new WebAssembly.Memory({ initial: 1 });
|
||
}
|
||
|
||
/** 在 memory 中寫入 iovec 陣列,回傳 iovs 指標 */
|
||
function writeIovecs(
|
||
view: DataView,
|
||
iovecs: Array<{ buf: number; buf_len: number }>,
|
||
startPtr: number,
|
||
): number {
|
||
for (let i = 0; i < iovecs.length; i++) {
|
||
view.setUint32(startPtr + i * 8, iovecs[i].buf, true);
|
||
view.setUint32(startPtr + i * 8 + 4, iovecs[i].buf_len, true);
|
||
}
|
||
return startPtr;
|
||
}
|
||
|
||
describe('createWasiShim', () => {
|
||
describe('fd_read(stdin)', () => {
|
||
it('一次讀取完整 stdin', () => {
|
||
const input = '{"key":"value"}';
|
||
const shim = createWasiShim(input);
|
||
const mem = makeFakeMemory();
|
||
shim.setMemory(mem);
|
||
|
||
const view = new DataView(mem.buffer);
|
||
const inputBytes = new TextEncoder().encode(input);
|
||
|
||
// 配置 buffer 區域(offset 100)和 iovec(offset 0)
|
||
const bufPtr = 100;
|
||
const iovsPtr = 0;
|
||
const nreadPtr = 50;
|
||
|
||
writeIovecs(view, [{ buf: bufPtr, buf_len: inputBytes.length }], iovsPtr);
|
||
|
||
const fd_read = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_read;
|
||
const result = fd_read(0, iovsPtr, 1, nreadPtr);
|
||
|
||
expect(result).toBe(0); // ESUCCESS
|
||
const nread = view.getUint32(nreadPtr, true);
|
||
expect(nread).toBe(inputBytes.length);
|
||
|
||
// 驗證讀取的內容
|
||
const readBytes = new Uint8Array(mem.buffer, bufPtr, nread);
|
||
expect(new TextDecoder().decode(readBytes)).toBe(input);
|
||
});
|
||
|
||
it('分多次讀取 stdin', () => {
|
||
const input = 'hello';
|
||
const shim = createWasiShim(input);
|
||
const mem = makeFakeMemory();
|
||
shim.setMemory(mem);
|
||
|
||
const view = new DataView(mem.buffer);
|
||
const fd_read = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_read;
|
||
|
||
// 第一次讀 3 bytes
|
||
writeIovecs(view, [{ buf: 200, buf_len: 3 }], 0);
|
||
fd_read(0, 0, 1, 50);
|
||
expect(view.getUint32(50, true)).toBe(3);
|
||
expect(new TextDecoder().decode(new Uint8Array(mem.buffer, 200, 3))).toBe('hel');
|
||
|
||
// 第二次讀剩餘 2 bytes
|
||
writeIovecs(view, [{ buf: 300, buf_len: 10 }], 0);
|
||
fd_read(0, 0, 1, 50);
|
||
expect(view.getUint32(50, true)).toBe(2);
|
||
expect(new TextDecoder().decode(new Uint8Array(mem.buffer, 300, 2))).toBe('lo');
|
||
|
||
// 第三次讀:stdin 已耗盡,nread = 0
|
||
writeIovecs(view, [{ buf: 400, buf_len: 10 }], 0);
|
||
fd_read(0, 0, 1, 50);
|
||
expect(view.getUint32(50, true)).toBe(0);
|
||
});
|
||
|
||
it('非 stdin fd 回傳 ENOSYS', () => {
|
||
const shim = createWasiShim('');
|
||
const mem = makeFakeMemory();
|
||
shim.setMemory(mem);
|
||
const view = new DataView(mem.buffer);
|
||
writeIovecs(view, [{ buf: 100, buf_len: 10 }], 0);
|
||
|
||
const fd_read = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_read;
|
||
expect(fd_read(1, 0, 1, 50)).toBe(76); // ENOSYS
|
||
expect(fd_read(2, 0, 1, 50)).toBe(76);
|
||
});
|
||
});
|
||
|
||
describe('fd_write(stdout/stderr)', () => {
|
||
it('寫入 stdout(fd=1)並可透過 getStdout 讀取', () => {
|
||
const shim = createWasiShim('');
|
||
const mem = makeFakeMemory();
|
||
shim.setMemory(mem);
|
||
|
||
const view = new DataView(mem.buffer);
|
||
const data = new TextEncoder().encode('{"valid":true}');
|
||
const bufPtr = 100;
|
||
new Uint8Array(mem.buffer).set(data, bufPtr);
|
||
writeIovecs(view, [{ buf: bufPtr, buf_len: data.length }], 0);
|
||
|
||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||
const result = fd_write(1, 0, 1, 50);
|
||
|
||
expect(result).toBe(0);
|
||
expect(view.getUint32(50, true)).toBe(data.length);
|
||
expect(shim.getStdout()).toBe('{"valid":true}');
|
||
});
|
||
|
||
it('寫入 stderr(fd=2)並可透過 getStderr 讀取', () => {
|
||
const shim = createWasiShim('');
|
||
const mem = makeFakeMemory();
|
||
shim.setMemory(mem);
|
||
|
||
const view = new DataView(mem.buffer);
|
||
const data = new TextEncoder().encode('error message');
|
||
const bufPtr = 100;
|
||
new Uint8Array(mem.buffer).set(data, bufPtr);
|
||
writeIovecs(view, [{ buf: bufPtr, buf_len: data.length }], 0);
|
||
|
||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||
fd_write(2, 0, 1, 50);
|
||
|
||
expect(shim.getStderr()).toBe('error message');
|
||
expect(shim.getStdout()).toBe(''); // stdout 不受影響
|
||
});
|
||
|
||
it('多次寫入 stdout 會合併', () => {
|
||
const shim = createWasiShim('');
|
||
const mem = makeFakeMemory();
|
||
shim.setMemory(mem);
|
||
|
||
const view = new DataView(mem.buffer);
|
||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||
|
||
const write = (text: string, bufPtr: number) => {
|
||
const data = new TextEncoder().encode(text);
|
||
new Uint8Array(mem.buffer).set(data, bufPtr);
|
||
writeIovecs(view, [{ buf: bufPtr, buf_len: data.length }], 0);
|
||
fd_write(1, 0, 1, 50);
|
||
};
|
||
|
||
write('{"valid":', 100);
|
||
write('true}', 200);
|
||
|
||
expect(shim.getStdout()).toBe('{"valid":true}');
|
||
});
|
||
|
||
it('非 stdout/stderr fd 回傳 ENOSYS', () => {
|
||
const shim = createWasiShim('');
|
||
const mem = makeFakeMemory();
|
||
shim.setMemory(mem);
|
||
const view = new DataView(mem.buffer);
|
||
writeIovecs(view, [{ buf: 100, buf_len: 5 }], 0);
|
||
|
||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||
expect(fd_write(0, 0, 1, 50)).toBe(76); // stdin 不能寫
|
||
expect(fd_write(3, 0, 1, 50)).toBe(76); // 其他 fd
|
||
});
|
||
});
|
||
|
||
describe('proc_exit', () => {
|
||
it('proc_exit(0) 拋出 Error(正常結束)', () => {
|
||
const shim = createWasiShim('');
|
||
const proc_exit = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).proc_exit;
|
||
expect(() => proc_exit(0)).toThrow('wasm exit: 0');
|
||
});
|
||
|
||
it('proc_exit(1) 拋出 Error(錯誤結束)', () => {
|
||
const shim = createWasiShim('');
|
||
const proc_exit = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).proc_exit;
|
||
expect(() => proc_exit(1)).toThrow('wasm exit: 1');
|
||
});
|
||
});
|
||
|
||
describe('其餘 syscall 回傳 ENOSYS', () => {
|
||
it('fd_seek 回傳 ENOSYS', () => {
|
||
const shim = createWasiShim('');
|
||
const fd_seek = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_seek;
|
||
expect(fd_seek(1, 0, 0, 0)).toBe(76);
|
||
});
|
||
|
||
it('sock_connect 相關 syscall 回傳 ENOSYS', () => {
|
||
const shim = createWasiShim('');
|
||
const imports = shim.imports.wasi_snapshot_preview1 as Record<string, Function>;
|
||
expect(imports.sock_recv()).toBe(76);
|
||
expect(imports.sock_send()).toBe(76);
|
||
expect(imports.sock_shutdown()).toBe(76);
|
||
});
|
||
|
||
it('path_open 回傳 ENOSYS', () => {
|
||
const shim = createWasiShim('');
|
||
const imports = shim.imports.wasi_snapshot_preview1 as Record<string, Function>;
|
||
expect(imports.path_open()).toBe(76);
|
||
expect(imports.path_create_directory()).toBe(76);
|
||
});
|
||
});
|
||
|
||
describe('setMemory 未呼叫時', () => {
|
||
it('fd_write 在 memory 未設定時拋出錯誤', () => {
|
||
const shim = createWasiShim('');
|
||
// 不呼叫 setMemory
|
||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||
expect(() => fd_write(1, 0, 1, 50)).toThrow('WASI memory not set');
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── credential-store-migration T4:secret_get host function ────────────────────
|
||
//
|
||
// 讀取 CF Workers per-script Secrets 的值:env[ref] 動態字串索引(§2.5/§5,T1.5 spike ②)。
|
||
// 兩層測試:
|
||
// 1. createArcrunHostFunctions 對 fake env 物件的行為(CRED_ 前綴放行、其他前綴拒絕、找不到回 null)
|
||
// 2. u6u.secret_get 的 WASI import wiring(pointer/memory-write 機制,比照 kv_get 同款測法)
|
||
|
||
function makeFakeEnv(overrides: Record<string, unknown> = {}): ArcrunHostEnv {
|
||
const fakeKv = {
|
||
get: async () => null,
|
||
put: async () => {},
|
||
delete: async () => {},
|
||
list: async () => ({ keys: [], list_complete: true, cacheStatus: null }),
|
||
} as unknown as KVNamespace;
|
||
return {
|
||
CREDENTIALS_KV: fakeKv,
|
||
RECIPES: fakeKv,
|
||
ENCRYPTION_KEY: 'deadbeef'.repeat(8),
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('createArcrunHostFunctions — secret_get', () => {
|
||
it('CRED_ 前綴的 ref 存在 → 回傳值', async () => {
|
||
const env = makeFakeEnv({ CRED_TELEGRAM_BOT_TOKEN_A1B2C3D4: 'sk-fake-secret-value' });
|
||
const hostFns = createArcrunHostFunctions(env, 'ak_test');
|
||
await expect(hostFns.secret_get!('CRED_TELEGRAM_BOT_TOKEN_A1B2C3D4')).resolves.toBe('sk-fake-secret-value');
|
||
});
|
||
|
||
it('CRED_ 前綴但 ref 不存在(env 沒有這個 key)→ null', async () => {
|
||
const env = makeFakeEnv();
|
||
const hostFns = createArcrunHostFunctions(env, 'ak_test');
|
||
await expect(hostFns.secret_get!('CRED_NOT_SET')).resolves.toBeNull();
|
||
});
|
||
|
||
it('非 CRED_ 前綴 → 一律拒絕回 null(即使 env 上真的有這個值,如 ENCRYPTION_KEY)', async () => {
|
||
const env = makeFakeEnv();
|
||
const hostFns = createArcrunHostFunctions(env, 'ak_test');
|
||
// ENCRYPTION_KEY 是 worker 自己的機密,WASM 不該透過 secret_get 拿到(安全邊界)
|
||
await expect(hostFns.secret_get!('ENCRYPTION_KEY')).resolves.toBeNull();
|
||
await expect(hostFns.secret_get!('CF_SECRETS_API_TOKEN')).resolves.toBeNull();
|
||
});
|
||
|
||
it('CRED_ 前綴但值非字串(型別不符)→ null', async () => {
|
||
const env = makeFakeEnv({ CRED_WEIRD: 12345 });
|
||
const hostFns = createArcrunHostFunctions(env, 'ak_test');
|
||
await expect(hostFns.secret_get!('CRED_WEIRD')).resolves.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('u6u.secret_get — WASI import wiring', () => {
|
||
// 誠實註記(撞牆記錄):vitest-pool-workers 環境的 WebAssembly 支援 JSPI,hostWrap() 因此把
|
||
// secret_get(以及既有的 kv_get / crypto_decrypt 等所有 async host function)包成
|
||
// `WebAssembly.Suspending` 物件而非一般函式——這類物件設計上只能當 WASM import 綁定使用,
|
||
// 不能在 JS 端直接 `fn(...)` 呼叫(會拋 "is not a function")。用 probe 測試證實
|
||
// kv_get 的 import 同樣是 `Suspending` 物件、同樣不可直接呼叫——這是既有架構的環境限制,
|
||
// 不是 secret_get 本身的缺陷;pointer/memory-write 機制無法在不起真實 WASM instance 的
|
||
// 單元測試裡直接驗證(真實路徑靠 T5 的 wrangler 部署端到端驗證涵蓋)。
|
||
// 這裡改測 hostWrap 不介入的同步路徑:沒注入 secret_get 時的 fallback。
|
||
it('沒有注入 secret_get host function → 回傳 1(未實作,同步 fallback 路徑,非 hostWrap 包裝)', () => {
|
||
const shim = createWasiShim('', {}); // 沒給 secret_get
|
||
const secretGetImport = (shim.imports.u6u as Record<string, Function>).secret_get;
|
||
expect(secretGetImport()).toBe(1);
|
||
});
|
||
});
|