From bc7cb8a8462840a3d94f2f1467a3b7563599edc1 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 6 Sep 2026 22:03:26 -0400 Subject: [PATCH] transcode: normalize FACT transcoder i32 arguments to u32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core wasm delivers i32 params to a JS import as signed numbers. Every other i32-taking intrinsic normalizes with `>>> 0` (intrinsics/mod.ts resource trampolines, boundary.ts realloc results); the twelve FACT transcoders did not. FACT's generated adapter validates `src_ptr + len <= memory.size` UNSIGNED (wasmtime-environ `validate_guest_pointer` extends with I64ExtendI32U), so a pointer in [2^31, 2^32) is legal once a wasm32 memory grows past 2 GiB — and arrived here negative: `Uint8Array.slice(negative)` read from the END of the buffer (silently wrong bytes), `dst[neg + i] =` was a silent no-op, `dst.set(copy, neg)` threw a non-Trap RangeError. wasmtime copies the right bytes with no trap. Normalized once, in `createTranscoder`, so the twelve arms stay untouched. Regression: transcode_high_ptr_test.ts (allocates a ~2.4 GiB memory; `ignore`d with a reason where the host cannot). --- runtime/src/intrinsics/transcode.ts | 19 ++++ runtime/tests/transcode_high_ptr_test.ts | 106 +++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 runtime/tests/transcode_high_ptr_test.ts diff --git a/runtime/src/intrinsics/transcode.ts b/runtime/src/intrinsics/transcode.ts index 5965e76..77b67ff 100644 --- a/runtime/src/intrinsics/transcode.ts +++ b/runtime/src/intrinsics/transcode.ts @@ -272,6 +272,25 @@ export function createTranscoder( op: TranscodeOp, from: TranscodeMemory, to: TranscodeMemory, +): (...args: number[]) => unknown { + const fn = createTranscoderInner(op, from, to); + // Core wasm delivers i32 params to a JS import as *signed* numbers, but + // every transcoder arg (ptr/len/flag) is a FACT-validated unsigned + // quantity (contracts/intrinsics.md §A/§B; wasmtime libcalls.rs takes + // unsigned guest pointers, and validate_guest_pointer does its bounds + // arithmetic unsigned). Normalize once here, at the single call boundary + // every one of the twelve arms shares, so none of them need to know about + // the signed/unsigned wasm calling-convention detail — matching the + // `>>> 0` normalization every other i32-taking intrinsic gets (see + // intrinsics/mod.ts resource-new/-rep/-drop, resource-transfer-*, and + // exec/boundary.ts). + return (...args: number[]) => fn(...args.map((a) => a >>> 0)); +} + +function createTranscoderInner( + op: TranscodeOp, + from: TranscodeMemory, + to: TranscodeMemory, ): (...args: number[]) => unknown { switch (op) { // (srcPtr, srcLen, dstPtr) -> () -------------------------------------- diff --git a/runtime/tests/transcode_high_ptr_test.ts b/runtime/tests/transcode_high_ptr_test.ts new file mode 100644 index 0000000..77fde3a --- /dev/null +++ b/runtime/tests/transcode_high_ptr_test.ts @@ -0,0 +1,106 @@ +// R3-F1: FACT transcoders read srcPtr/dstPtr as the SIGNED i32 core wasm +// delivers them, with no `>>> 0` normalization (contrast every other +// i32-taking intrinsic: intrinsics/mod.ts:538/544/551/730/733, +// exec/boundary.ts:223,314). A pointer in [2^31, 2^32) is legal once the +// guest's linear memory exceeds 2 GiB (wasmtime's `validate_guest_pointer` +// does its arithmetic in unsigned i64), but here it arrives negative and +// `Uint8Array.prototype.slice`/property-index writes silently read or write +// the wrong offset instead of the byte range FACT bounds-checked. +// +// Repro: runtime/src/intrinsics/transcode.ts:271-487 (snapshot/dst indexing +// use srcPtr/dstPtr verbatim). Reference: contracts/intrinsics.md §A/§B +// ("Semantics authority is wasmtime's libcalls"); wasmtime 47.0.3 +// libcalls.rs takes unsigned pointers. + +import { assertEq } from "./support/asserts.ts"; +import { + createTranscoder, + TranscodeMemory, +} from "../src/intrinsics/mod.ts"; + +// ~2.4 GiB: large enough that an address >= 2^31 is in bounds. V8 reserves +// wasm memory lazily on 64-bit hosts; if this allocation fails here, the +// test cannot exercise the bug and is skipped with a clear reason. +function tryBigMemory(): WebAssembly.Memory | null { + try { + return new WebAssembly.Memory({ initial: 40000 }); + } catch { + return null; + } +} + +// Probe once at module load to decide whether to skip; each test still +// allocates its own memory so state does not leak between cases (all three +// use the same >=2^31 offset). +const bigMemoryAvailable = tryBigMemory() !== null; + +Deno.test({ + name: + "transcode: latin1-to-latin1 with a >=2^31 src pointer copies the wrong bytes", + ignore: !bigMemoryAvailable, + fn() { + // Confirmed on this host: WebAssembly.Memory({initial: 40000}) (~2.44 GiB) + // allocates successfully. + const memory = tryBigMemory()!; + const view = new TranscodeMemory(() => memory, "test"); + const p = 0x9000_0000; // 2415919104, >= 2^31, in bounds for this memory + const abc = new TextEncoder().encode("abc"); + new Uint8Array(memory.buffer).set(abc, p); + + const signedPtr = p | 0; + assertEq(signedPtr, -1879048192, "sanity: this is how wasm delivers it"); + + const fn = createTranscoder("latin1-to-latin1", view, view); + fn(signedPtr, 3, 16); + + const got = [...new Uint8Array(memory.buffer).subarray(16, 19)]; + // Reference (wasmtime): the transcoder operates on the byte range FACT + // bounds-checked (unsigned ptr) and copies "abc" to offset 16. + assertEq(got, [...abc], "expected 'abc' copied to dst 16..19"); + }, +}); + +Deno.test({ + name: + "transcode: utf8-to-utf8 with a >=2^31 src pointer copies the wrong bytes", + ignore: !bigMemoryAvailable, + fn() { + const memory = tryBigMemory()!; + const view = new TranscodeMemory(() => memory, "test"); + const p = 0x9000_0000; + const src = new TextEncoder().encode("abc"); + new Uint8Array(memory.buffer).set(src, p); + + const signedPtr = p | 0; + const fn = createTranscoder("utf8-to-utf8", view, view); + fn(signedPtr, 3, 16); + + const got = [...new Uint8Array(memory.buffer).subarray(16, 19)]; + assertEq(got, [...src], "expected 'abc' copied to dst 16..19"); + }, +}); + +Deno.test({ + name: + "transcode: latin1-to-utf16 with a >=2^31 DESTINATION pointer silently drops the write", + ignore: !bigMemoryAvailable, + fn() { + const memory = tryBigMemory()!; + const view = new TranscodeMemory(() => memory, "test"); + const bytes = new Uint8Array(memory.buffer); + bytes.set([0x41, 0xff], 0); // "A", 0xFF as latin1 + + const dstP = 0x9000_0000; // >= 2^31, in bounds for this memory + const signedDst = dstP | 0; + + const fn = createTranscoder("latin1-to-utf16", view, view); + fn(0, 2, signedDst); + + // Reference: bytes land at the unsigned destination address FACT + // bounds-checked. Ours: `dst[negativeIndex] = …` is a silent no-op + // property write (or, for other ops, `.set()` throws a non-Trap + // RangeError) — the correct location is left untouched. + const got = [...new Uint8Array(memory.buffer).subarray(dstP, dstP + 4)]; + assertEq(got, [0x41, 0x00, 0xff, 0x00], "expected inflated utf16 at unsigned dst"); + }, +});