Files
Arcrun/cypher-executor/tests/wasi-shim.test.ts
T
Claude c3c0a8b17d feat(credential-store-migration): T4 wasi-shim secret_get host function
新增 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
2026-07-03 22:56:42 +00:00

282 lines
11 KiB
TypeScript
Raw 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.
/**
* 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.MemoryVitest 環境支援)
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_readstdin', () => {
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)和 iovecoffset 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_writestdout/stderr', () => {
it('寫入 stdoutfd=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('寫入 stderrfd=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 T4secret_get host function ────────────────────
//
// 讀取 CF Workers per-script Secrets 的值:env[ref] 動態字串索引(§2.5/§5T1.5 spike ②)。
// 兩層測試:
// 1. createArcrunHostFunctions 對 fake env 物件的行為(CRED_ 前綴放行、其他前綴拒絕、找不到回 null)
// 2. u6u.secret_get 的 WASI import wiringpointer/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 支援 JSPIhostWrap() 因此把
// 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);
});
});