diff --git a/src/js/node/wasi.ts b/src/js/node/wasi.ts index 2f952a86120e..157fdc9ffc17 100644 --- a/src/js/node/wasi.ts +++ b/src/js/node/wasi.ts @@ -700,6 +700,42 @@ var require_wasi = __commonJS({ } return stats; }; + // Same rule as uvwasi_serdes_check_bounds (what Node applies to every guest + // pointer): the start must lie inside linear memory even when len is 0. Every + // hostcall checks all of its pointers before it touches guest memory, the host + // fs or FD_MAP, so a bad pointer is reported as EOVERFLOW with nothing done. + const CHECK_BOUNDS = (ptr, len) => { + const { byteLength } = this.memory.buffer; + if (!(ptr >>> 0 === ptr && len >>> 0 === len && ptr < byteLength && len <= byteLength - ptr)) { + throw new types_1.WASIError(constants_1.WASI_EOVERFLOW); + } + }; + // args_get/environ_get and args_sizes_get/environ_sizes_get share one layout: a + // table of u32 pointers, one per string, and the NUL-terminated strings packed + // into a buffer whose size the *_sizes_get call reported. + const argStrings = () => args.map(a => `${a}\0`); + const environStrings = () => Object.entries(this.env).map(([key, value]) => `${key}=${value}\0`); + const byteLengthOf = strings => strings.reduce((acc, s) => acc + Buffer.byteLength(s), 0); + const writeStringTable = (strings, tablePtr, bufPtr) => { + this.refreshMemory(); + CHECK_BOUNDS(tablePtr, strings.length * 4); + CHECK_BOUNDS(bufPtr, byteLengthOf(strings)); + const buffer = Buffer.from(this.memory.buffer); + for (const s of strings) { + this.view.setUint32(tablePtr, bufPtr, true); + tablePtr += 4; + bufPtr += buffer.write(s, bufPtr); + } + return constants_1.WASI_ESUCCESS; + }; + const writeStringTableSizes = (strings, countPtr, bufSizePtr) => { + this.refreshMemory(); + CHECK_BOUNDS(countPtr, 4); + CHECK_BOUNDS(bufSizePtr, 4); + this.view.setUint32(countPtr, strings.length, true); + this.view.setUint32(bufSizePtr, byteLengthOf(strings), true); + return constants_1.WASI_ESUCCESS; + }; // Resolve a guest-supplied path against the directory backing `stats` and // verify the result cannot escape that directory, either lexically // ("..", absolute paths) or through a symlink that already exists on the @@ -772,44 +808,15 @@ var require_wasi = __commonJS({ } }; this.wasiImport = { - args_get: (argv, argvBuf) => { - this.refreshMemory(); - let coffset = argv; - let offset = argvBuf; - args.forEach(a => { - this.view.setUint32(coffset, offset, true); - coffset += 4; - offset += Buffer.from(this.memory.buffer).write(`${a}\0`, offset); - }); - return constants_1.WASI_ESUCCESS; - }, - args_sizes_get: (argc, argvBufSize) => { - this.refreshMemory(); - this.view.setUint32(argc, args.length, true); - const size = args.reduce((acc, a) => acc + Buffer.byteLength(a) + 1, 0); - this.view.setUint32(argvBufSize, size, true); - return constants_1.WASI_ESUCCESS; - }, - environ_get: (environ, environBuf) => { - this.refreshMemory(); - let coffset = environ; - let offset = environBuf; - Object.entries(this.env).forEach(([key, value]) => { - this.view.setUint32(coffset, offset, true); - coffset += 4; - offset += Buffer.from(this.memory.buffer).write(`${key}=${value}\0`, offset); - }); - return constants_1.WASI_ESUCCESS; - }, - environ_sizes_get: (environCount, environBufSize) => { + args_get: wrap((argv, argvBuf) => writeStringTable(argStrings(), argv, argvBuf)), + args_sizes_get: wrap((argc, argvBufSize) => writeStringTableSizes(argStrings(), argc, argvBufSize)), + environ_get: wrap((environ, environBuf) => writeStringTable(environStrings(), environ, environBuf)), + environ_sizes_get: wrap((environCount, environBufSize) => + writeStringTableSizes(environStrings(), environCount, environBufSize), + ), + clock_res_get: wrap((clockId, resolution) => { this.refreshMemory(); - const envProcessed = Object.entries(this.env).map(([key, value]) => `${key}=${value}\0`); - const size = envProcessed.reduce((acc, e) => acc + Buffer.byteLength(e), 0); - this.view.setUint32(environCount, envProcessed.length, true); - this.view.setUint32(environBufSize, size, true); - return constants_1.WASI_ESUCCESS; - }, - clock_res_get: (clockId, resolution) => { + CHECK_BOUNDS(resolution, 8); let res; switch (clockId) { case constants_1.WASI_CLOCK_MONOTONIC: @@ -828,16 +835,17 @@ var require_wasi = __commonJS({ } this.view.setBigUint64(resolution, res); return constants_1.WASI_ESUCCESS; - }, - clock_time_get: (clockId, _precision, time) => { + }), + clock_time_get: wrap((clockId, _precision, time) => { this.refreshMemory(); + CHECK_BOUNDS(time, 8); const n = now(clockId); if (n === null) { return constants_1.WASI_EINVAL; } this.view.setBigUint64(time, BigInt(n), true); return constants_1.WASI_ESUCCESS; - }, + }), fd_advise: wrap((fd, _offset, _len, _advice) => { CHECK_FD(fd, constants_1.WASI_RIGHT_FD_ADVISE); return constants_1.WASI_ENOSYS; @@ -860,6 +868,7 @@ var require_wasi = __commonJS({ fd_fdstat_get: wrap((fd, bufPtr) => { const stats = CHECK_FD(fd, BigInt(0)); this.refreshMemory(); + CHECK_BOUNDS(bufPtr, 24); if (stats.filetype == null) { throw Error("stats.filetype must be set"); } @@ -895,8 +904,9 @@ var require_wasi = __commonJS({ }), fd_filestat_get: wrap((fd, bufPtr) => { const stats = CHECK_FD(fd, constants_1.WASI_RIGHT_FD_FILESTAT_GET); - const rstats = this.fstatSync(stats.real); this.refreshMemory(); + CHECK_BOUNDS(bufPtr, 64); + const rstats = this.fstatSync(stats.real); this.view.setBigUint64(bufPtr, BigInt(rstats.dev), true); bufPtr += 8; this.view.setBigUint64(bufPtr, BigInt(rstats.ino), true); @@ -952,6 +962,7 @@ var require_wasi = __commonJS({ fd_prestat_get: wrap((fd, bufPtr) => { const stats = CHECK_FD(fd, BigInt(0)); this.refreshMemory(); + CHECK_BOUNDS(bufPtr, 8); this.view.setUint8(bufPtr, constants_1.WASI_PREOPENTYPE_DIR); this.view.setUint32(bufPtr + 4, Buffer.byteLength(stats.fakePath ?? stats.path ?? ""), true); return constants_1.WASI_ESUCCESS; @@ -959,6 +970,7 @@ var require_wasi = __commonJS({ fd_prestat_dir_name: wrap((fd, pathPtr, pathLen) => { const stats = CHECK_FD(fd, BigInt(0)); this.refreshMemory(); + CHECK_BOUNDS(pathPtr, pathLen); Buffer.from(this.memory.buffer).write(stats.fakePath ?? stats.path ?? "", pathPtr, pathLen, "utf8"); return constants_1.WASI_ESUCCESS; }), @@ -1086,6 +1098,8 @@ var require_wasi = __commonJS({ fd_readdir: wrap((fd, bufPtr, bufLen, cookie, bufusedPtr) => { const stats = CHECK_FD(fd, constants_1.WASI_RIGHT_FD_READDIR); this.refreshMemory(); + CHECK_BOUNDS(bufPtr, bufLen); + CHECK_BOUNDS(bufusedPtr, 4); const entries = fs.readdirSync(stats.path, { withFileTypes: true }); const startPtr = bufPtr; for (let i = Number(cookie); i < entries.length; i += 1) { @@ -1162,6 +1176,7 @@ var require_wasi = __commonJS({ fd_seek: wrap((fd, offset, whence, newOffsetPtr) => { const stats = CHECK_FD(fd, constants_1.WASI_RIGHT_FD_SEEK); this.refreshMemory(); + CHECK_BOUNDS(newOffsetPtr, 8); switch (whence) { case constants_1.WASI_WHENCE_CUR: stats.offset = (stats.offset ? stats.offset : BigInt(0)) + BigInt(offset); @@ -1183,6 +1198,7 @@ var require_wasi = __commonJS({ fd_tell: wrap((fd, offsetPtr) => { const stats = CHECK_FD(fd, constants_1.WASI_RIGHT_FD_TELL); this.refreshMemory(); + CHECK_BOUNDS(offsetPtr, 8); if (!stats.offset) { stats.offset = BigInt(0); } @@ -1200,6 +1216,7 @@ var require_wasi = __commonJS({ return constants_1.WASI_EINVAL; } this.refreshMemory(); + CHECK_BOUNDS(pathPtr, pathLen); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); fs.mkdirSync(RESOLVE_PATH(stats, p)); return constants_1.WASI_ESUCCESS; @@ -1210,6 +1227,8 @@ var require_wasi = __commonJS({ return constants_1.WASI_EINVAL; } this.refreshMemory(); + CHECK_BOUNDS(pathPtr, pathLen); + CHECK_BOUNDS(bufPtr, 64); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); const resolved = RESOLVE_PATH(stats, p); let rstats; @@ -1241,6 +1260,7 @@ var require_wasi = __commonJS({ return constants_1.WASI_EINVAL; } this.refreshMemory(); + CHECK_BOUNDS(pathPtr, pathLen); const rstats = this.fstatSync(stats.real); let atim = rstats.atime; let mtim = rstats.mtime; @@ -1274,6 +1294,8 @@ var require_wasi = __commonJS({ return constants_1.WASI_EINVAL; } this.refreshMemory(); + CHECK_BOUNDS(oldPath, oldPathLen); + CHECK_BOUNDS(newPath, newPathLen); const op = Buffer.from(this.memory.buffer, oldPath, oldPathLen).toString(); const np = Buffer.from(this.memory.buffer, newPath, newPathLen).toString(); fs.linkSync(RESOLVE_PATH(ostats, op), RESOLVE_PATH(nstats, np)); @@ -1342,6 +1364,8 @@ var require_wasi = __commonJS({ neededInheriting |= constants_1.WASI_RIGHT_FD_SEEK; } this.refreshMemory(); + CHECK_BOUNDS(pathPtr, pathLen); + CHECK_BOUNDS(fdPtr, 4); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); if (p == "dev/tty") { this.view.setUint32(fdPtr, constants_1.WASI_STDIN_FILENO, true); @@ -1444,6 +1468,9 @@ var require_wasi = __commonJS({ return constants_1.WASI_EINVAL; } this.refreshMemory(); + CHECK_BOUNDS(pathPtr, pathLen); + CHECK_BOUNDS(buf, bufLen); + CHECK_BOUNDS(bufused, 4); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); const full = RESOLVE_PATH(stats, p); const r = fs.readlinkSync(full); @@ -1457,6 +1484,7 @@ var require_wasi = __commonJS({ return constants_1.WASI_EINVAL; } this.refreshMemory(); + CHECK_BOUNDS(pathPtr, pathLen); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); fs.rmdirSync(RESOLVE_PATH(stats, p)); return constants_1.WASI_ESUCCESS; @@ -1468,6 +1496,8 @@ var require_wasi = __commonJS({ return constants_1.WASI_EINVAL; } this.refreshMemory(); + CHECK_BOUNDS(oldPath, oldPathLen); + CHECK_BOUNDS(newPath, newPathLen); const op = Buffer.from(this.memory.buffer, oldPath, oldPathLen).toString(); const np = Buffer.from(this.memory.buffer, newPath, newPathLen).toString(); fs.renameSync(RESOLVE_PATH(ostats, op), RESOLVE_PATH(nstats, np)); @@ -1479,6 +1509,8 @@ var require_wasi = __commonJS({ return constants_1.WASI_EINVAL; } this.refreshMemory(); + CHECK_BOUNDS(oldPath, oldPathLen); + CHECK_BOUNDS(newPath, newPathLen); const op = Buffer.from(this.memory.buffer, oldPath, oldPathLen).toString(); const np = Buffer.from(this.memory.buffer, newPath, newPathLen).toString(); fs.symlinkSync(op, RESOLVE_PATH(stats, np)); @@ -1490,17 +1522,23 @@ var require_wasi = __commonJS({ return constants_1.WASI_EINVAL; } this.refreshMemory(); + CHECK_BOUNDS(pathPtr, pathLen); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); fs.unlinkSync(RESOLVE_PATH(stats, p)); return constants_1.WASI_ESUCCESS; }), - poll_oneoff: (sin, sout, nsubscriptions, neventsPtr) => { + poll_oneoff: wrap((sin, sout, nsubscriptions, neventsPtr) => { let nevents = 0; let waitTimeNs = BigInt(0); let fd = -1; let fd_type = "read"; let fd_timeout_ms = 0; this.refreshMemory(); + // preview1 sizeof(subscription_t) is 48 and sizeof(event_t) is 32; the guest + // allocated whole records whether or not every field of an event gets filled in. + CHECK_BOUNDS(sin, nsubscriptions * 48); + CHECK_BOUNDS(sout, nsubscriptions * 32); + CHECK_BOUNDS(neventsPtr, 4); let last_sin = sin; for (let i = 0; i < nsubscriptions; i += 1) { const userdata = this.view.getBigUint64(sin, true); @@ -1602,7 +1640,7 @@ var require_wasi = __commonJS({ } } return constants_1.WASI_ESUCCESS; - }, + }), proc_exit: rval => { bindings.exit(rval); return constants_1.WASI_ESUCCESS; @@ -1614,14 +1652,15 @@ var require_wasi = __commonJS({ bindings.kill(constants_1.SIGNAL_MAP[sig]); return constants_1.WASI_ESUCCESS; }, - random_get: (bufPtr, bufLen) => { + random_get: wrap((bufPtr, bufLen) => { this.refreshMemory(); + CHECK_BOUNDS(bufPtr, bufLen); // getRandomValues takes one integer-typed view and ignores any further // arguments, so a bare `buffer, bufPtr, bufLen` randomized all of linear // memory rather than the requested window. crypto.getRandomValues(new Uint8Array(this.memory.buffer, bufPtr, bufLen)); return constants_1.WASI_ESUCCESS; - }, + }), sched_yield() { return constants_1.WASI_ESUCCESS; }, diff --git a/test/js/bun/wasm/pointer-validation-guest.c b/test/js/bun/wasm/pointer-validation-guest.c new file mode 100644 index 000000000000..bd46cf79c572 --- /dev/null +++ b/test/js/bun/wasm/pointer-validation-guest.c @@ -0,0 +1,93 @@ +// Source of pointer-validation-guest.wasm, used by wasi.test.js. Freestanding (no +// wasi-libc), so the guest only does what the test needs: read argv and environ the +// way a libc start-up would, then hand the host pointers that do not fit in linear +// memory and report the errnos it gets back, all through a single fd_write. +// +// clang --target=wasm32 -Oz -nostdlib -Wl,--strip-all -Wl,-z,stack-size=4096 \ +// -o pointer-validation-guest.wasm pointer-validation-guest.c + +typedef unsigned int u32; +typedef unsigned long long u64; + +#define HOSTCALL(name) __attribute__((import_module("wasi_snapshot_preview1"), import_name(#name))) u32 name + +HOSTCALL(args_sizes_get)(u32 *argc, u32 *argv_buf_size); +HOSTCALL(args_get)(u32 *argv, char *argv_buf); +HOSTCALL(environ_sizes_get)(u32 *environ_count, u32 *environ_buf_size); +HOSTCALL(environ_get)(u32 *environ, char *environ_buf); +HOSTCALL(clock_time_get)(u32 clock_id, u64 precision, u64 *time); +HOSTCALL(fd_fdstat_get)(u32 fd, void *fdstat); +HOSTCALL(random_get)(void *buf, u32 buf_len); +HOSTCALL(fd_write)(u32 fd, const void *iovs, u32 iovs_len, u32 *nwritten); + +static char out[512]; +static u32 out_len; + +static void put(const char *s) { + while (*s && out_len < sizeof(out)) out[out_len++] = *s++; +} + +static void put_u32(u32 value) { + char digits[10]; + u32 n = 0; + do { + digits[n++] = '0' + value % 10; + value /= 10; + } while (value); + while (n) { + char digit[2] = {digits[--n], 0}; + put(digit); + } +} + +static void put_string_table(u32 (*sizes)(u32 *, u32 *), u32 (*get)(u32 *, char *)) { + static u32 table[16]; + static char buf[1024]; + u32 count, buf_size; + u32 err = sizes(&count, &buf_size); + if (err == 0 && count <= sizeof(table) / sizeof(table[0]) && buf_size <= sizeof(buf)) err = get(table, buf); + if (err != 0) { + put(" errno "); + put_u32(err); + return; + } + for (u32 i = 0; i < count; i++) { + put(" "); + put((const char *)table[i]); + } +} + +void _start(void) { + put("args:"); + put_string_table(args_sizes_get, args_get); + put("\nenviron:"); + put_string_table(environ_sizes_get, environ_get); + + // The address just past the last byte of linear memory, and an address in the top + // half of the 32-bit address space, which reaches a JS host as a negative i32. + char *end = (char *)(__builtin_wasm_memory_size(0) * 65536u); + char *high = (char *)0xfffffff0u; + u32 scratch; + u32 errnos[] = { + fd_fdstat_get(1, end), + fd_fdstat_get(1, high), + args_sizes_get((u32 *)end, &scratch), + environ_sizes_get((u32 *)high, &scratch), + clock_time_get(1, 0, (u64 *)end), + random_get(high, 4), + random_get(end - 2, 4), + }; + put("\nerrnos:"); + for (u32 i = 0; i < sizeof(errnos) / sizeof(errnos[0]); i++) { + put(" "); + put_u32(errnos[i]); + } + put("\n"); + + struct { + const char *buf; + u32 len; + } iov = {out, out_len}; + u32 written; + fd_write(1, &iov, 1, &written); +} diff --git a/test/js/bun/wasm/pointer-validation-guest.wasm b/test/js/bun/wasm/pointer-validation-guest.wasm new file mode 100644 index 000000000000..17e1ab75d209 Binary files /dev/null and b/test/js/bun/wasm/pointer-validation-guest.wasm differ diff --git a/test/js/bun/wasm/wasi.test.js b/test/js/bun/wasm/wasi.test.js index bfb1099630bc..179346811e48 100644 --- a/test/js/bun/wasm/wasi.test.js +++ b/test/js/bun/wasm/wasi.test.js @@ -163,3 +163,226 @@ it("path_* syscalls cannot escape the preopened directory", () => { ); expect(wasi.FD_MAP.has(4)).toBe(true); }); + +// Shared by the pointer-validation tests: a 1-page memory, the directory preopened on +// fd 3 (holding file.txt and, off Windows, a symlink to it), file.txt open on its own +// descriptor, and the guest path strings the hostcalls are given. +function setupGuest(dir) { + if (!isWindows) { + fs.symlinkSync("file.txt", path.join(String(dir), "link")); + } + const wasi = new WASI({ version: "preview1", args: ["argv0"], env: { K: "v" }, preopens: { "/": String(dir) } }); + wasi.setMemory(new WebAssembly.Memory({ initial: 1 })); + const memory = Buffer.from(wasi.memory.buffer); + const view = new DataView(wasi.memory.buffer); + const END = wasi.memory.buffer.byteLength; + + const WASI_RIGHT_FD_READ = BigInt(2); + const WASI_RIGHT_FD_SEEK = BigInt(4); + const WASI_RIGHT_FD_TELL = BigInt(32); + const WASI_RIGHT_FD_FILESTAT_GET = BigInt(1 << 21); + const preopenFd = 3; + const FILE_PATH = 1024; + const fileLen = memory.write("file.txt", FILE_PATH); + const LINK_PATH = 1088; + const linkLen = memory.write("link", LINK_PATH); + const NEW_PATH = 1152; + const newLen = memory.write("new-entry", NEW_PATH); + const fdPtr = 2048; + const rights = WASI_RIGHT_FD_READ | WASI_RIGHT_FD_SEEK | WASI_RIGHT_FD_TELL | WASI_RIGHT_FD_FILESTAT_GET; + expect(wasi.wasiImport.path_open(preopenFd, 0, FILE_PATH, fileLen, 0, rights, BigInt(0), 0, fdPtr)).toBe(0); + const fileFd = view.getUint32(fdPtr, true); + + // One clock subscription (monotonic, relative, timeout 0) for poll_oneoff. + const SUB = 4096; + view.setBigUint64(SUB, BigInt(1), true); + view.setUint8(SUB + 8, 0); + view.setUint32(SUB + 16, 1, true); + view.setBigUint64(SUB + 24, BigInt(0), true); + + return { + wasi, + imports: wasi.wasiImport, + memory, + view, + END, + preopenFd, + fileFd, + paths: { FILE_PATH, fileLen, LINK_PATH, linkLen, NEW_PATH, newLen }, + SUB, + }; +} + +it("hostcalls return EOVERFLOW for out-of-bounds guest pointers before doing anything", () => { + using dir = tempDir("wasi-pointer-oob", { + "file.txt": "0123456789", + }); + const { wasi, imports: w, memory, END, preopenFd: DIR, fileFd: FILE, paths, SUB } = setupGuest(dir); + const { FILE_PATH, fileLen, LINK_PATH, linkLen, NEW_PATH, newLen } = paths; + const WASI_EOVERFLOW = 61; + const WASI_O_CREAT = 1; + const WASI_RIGHT_FD_READ = BigInt(2); + const WASI_RIGHT_FD_WRITE = BigInt(64); + const ZERO = BigInt(0); // an i64 argument that is not under test + const OOB = END + 1000; + // Valid scratch space for whichever pointer argument a row is not testing. + const OK = 8192; + const OUT = 16384; + + // [description, hostcall, ...arguments]; exactly one pointer (or pointer + length + // pair) in each row does not fit in memory. Some rows leave the pointer inside + // memory but not the range it addresses, and some are the boundary itself: a + // range that starts at the end of memory is out of bounds even when it is empty. + // A wasm guest passes addresses >= 2**31 to the host as negative i32s. + const rows = [ + ["args_get argv", "args_get", END - 2, OK], + ["args_get argvBuf (argv0 needs 6 bytes)", "args_get", OK, END - 2], + ["args_sizes_get argc", "args_sizes_get", OOB, OK], + ["args_sizes_get argvBufSize", "args_sizes_get", OK, END - 2], + ["environ_get environ", "environ_get", END - 2, OK], + ["environ_get environBuf (K=v needs 4 bytes)", "environ_get", OK, END - 2], + ["environ_sizes_get count", "environ_sizes_get", OOB, OK], + ["environ_sizes_get size", "environ_sizes_get", OK, END - 2], + ["clock_res_get resolution", "clock_res_get", 1, END - 4], + ["clock_res_get resolution, unknown clock", "clock_res_get", 99, OOB], + ["clock_time_get time", "clock_time_get", 1, ZERO, END - 4], + ["clock_time_get time, unknown clock", "clock_time_get", 99, ZERO, OOB], + ["fd_fdstat_get buf (24 bytes)", "fd_fdstat_get", 0, END - 23], + ["fd_fdstat_get buf negative", "fd_fdstat_get", 0, -24], + ["fd_filestat_get buf (64 bytes)", "fd_filestat_get", FILE, END - 8], + ["fd_prestat_get buf (8 bytes)", "fd_prestat_get", DIR, END - 4], + ["fd_prestat_dir_name path past end", "fd_prestat_dir_name", DIR, OOB, 1], + ["fd_prestat_dir_name path at end, len 1", "fd_prestat_dir_name", DIR, END, 1], + ["fd_prestat_dir_name path at end, len 0", "fd_prestat_dir_name", DIR, END, 0], + ["fd_prestat_dir_name len overruns", "fd_prestat_dir_name", DIR, END - 1, 2], + ["fd_readdir buf", "fd_readdir", DIR, END - 8, 256, ZERO, OUT], + ["fd_readdir bufused", "fd_readdir", DIR, OK, 256, ZERO, END - 2], + ["fd_seek newoffset", "fd_seek", FILE, ZERO, 0, END - 7], + ["fd_tell offset", "fd_tell", FILE, END - 7], + ["path_create_directory path", "path_create_directory", DIR, END - 2, newLen], + ["path_filestat_get path", "path_filestat_get", DIR, 0, OOB, fileLen, OUT], + ["path_filestat_get buf (64 bytes)", "path_filestat_get", DIR, 0, FILE_PATH, fileLen, END - 8], + ["path_filestat_set_times path", "path_filestat_set_times", DIR, 0, OOB, fileLen, ZERO, ZERO, 0], + ["path_link old path", "path_link", DIR, 0, OOB, fileLen, DIR, NEW_PATH, newLen], + ["path_link new path", "path_link", DIR, 0, FILE_PATH, fileLen, DIR, END - 2, newLen], + ["path_open path", "path_open", DIR, 0, END - 2, fileLen, 0, WASI_RIGHT_FD_READ, ZERO, 0, OUT], + ["path_open fd", "path_open", DIR, 0, FILE_PATH, fileLen, 0, WASI_RIGHT_FD_READ, ZERO, 0, END - 3], + ["path_open O_CREAT fd", "path_open", DIR, 0, NEW_PATH, newLen, WASI_O_CREAT, WASI_RIGHT_FD_WRITE, ZERO, 0, OOB], + ["path_readlink path", "path_readlink", DIR, OOB, linkLen, OK, 64, OUT], + ["path_readlink buf", "path_readlink", DIR, LINK_PATH, linkLen, END - 2, 64, OUT], + ["path_readlink bufused", "path_readlink", DIR, LINK_PATH, linkLen, OK, 64, END - 2], + ["path_remove_directory path", "path_remove_directory", DIR, OOB, 6], + ["path_rename old path", "path_rename", DIR, OOB, fileLen, DIR, NEW_PATH, newLen], + ["path_rename new path", "path_rename", DIR, FILE_PATH, fileLen, DIR, END - 2, newLen], + ["path_symlink old path", "path_symlink", OOB, fileLen, DIR, NEW_PATH, newLen], + ["path_symlink new path", "path_symlink", FILE_PATH, fileLen, DIR, END - 2, newLen], + ["path_unlink_file path", "path_unlink_file", DIR, END - 2, fileLen], + ["poll_oneoff in (48 bytes per subscription)", "poll_oneoff", END - 16, OUT, 1, OK], + ["poll_oneoff out (32 bytes per event)", "poll_oneoff", SUB, END - 16, 1, OK], + ["poll_oneoff nevents", "poll_oneoff", SUB, OUT, 1, END - 2], + ["poll_oneoff nsubscriptions too large for memory", "poll_oneoff", SUB, OUT, 0x10000000, OK], + ["poll_oneoff no subscriptions, in/out at end", "poll_oneoff", END, END, 0, OK], + ["random_get buf past end", "random_get", OOB, 4], + ["random_get len overruns", "random_get", END - 2, 4], + ["random_get empty buf at end", "random_get", END, 0], + ["random_get len negative", "random_get", OK, -1], + ]; + + const fdsBefore = [...wasi.FD_MAP.keys()]; + const entriesBefore = fs.readdirSync(String(dir)).sort(); + const results = {}; + for (const [name, hostcall, ...args] of rows) { + const before = Buffer.from(memory); + let result; + try { + result = w[hostcall](...args); + } catch (e) { + result = `threw ${e.constructor.name}`; + } + if (!before.equals(memory)) { + result = `${result}, guest memory modified`; + before.copy(memory); + } + results[name] = result; + } + + expect(results).toEqual(Object.fromEntries(rows.map(([name]) => [name, WASI_EOVERFLOW]))); + expect([...wasi.FD_MAP.keys()]).toEqual(fdsBefore); + expect(fs.readdirSync(String(dir)).sort()).toEqual(entriesBefore); + expect([w.fd_close(FILE), w.fd_close(DIR)]).toEqual([0, 0]); +}); + +it("hostcalls accept pointers whose range ends exactly at the end of memory", () => { + using dir = tempDir("wasi-pointer-boundary", { + "file.txt": "0123456789", + }); + const { imports: w, memory, view, END, preopenFd: DIR, fileFd: FILE, paths, SUB } = setupGuest(dir); + const WASI_ESUCCESS = 0; + const u32 = ptr => view.getUint32(ptr, true); + const u64 = ptr => view.getBigUint64(ptr, true); + + expect({ + fd_fdstat_get: [w.fd_fdstat_get(FILE, END - 24), view.getUint8(END - 24)], + fd_filestat_get: [w.fd_filestat_get(FILE, END - 64), u64(END - 64 + 32)], + fd_prestat_get: [w.fd_prestat_get(DIR, END - 8), u32(END - 4)], + fd_prestat_dir_name: [w.fd_prestat_dir_name(DIR, END - 1, 1), memory.toString("utf8", END - 1)], + args_sizes_get: [w.args_sizes_get(END - 4, END - 8), u32(END - 4), u32(END - 8)], + args_get: [w.args_get(END - 4, END - 10), u32(END - 4), memory.toString("utf8", END - 10, END - 4)], + environ_sizes_get: [w.environ_sizes_get(END - 4, END - 8), u32(END - 4), u32(END - 8)], + environ_get: [w.environ_get(END - 4, END - 8), u32(END - 4), memory.toString("utf8", END - 8, END - 4)], + clock_res_get: w.clock_res_get(1, END - 8), + clock_time_get: [w.clock_time_get(1, BigInt(0), END - 8), u64(END - 8) > 0], + fd_seek: [w.fd_seek(FILE, BigInt(3), 0, END - 8), u64(END - 8)], + fd_tell: [w.fd_tell(FILE, END - 8), u64(END - 8)], + path_filestat_get: [w.path_filestat_get(DIR, 0, paths.FILE_PATH, paths.fileLen, END - 64), u64(END - 64 + 32)], + poll_oneoff: [w.poll_oneoff(SUB, END - 36, 1, END - 4), u32(END - 4)], + fd_readdir: [w.fd_readdir(DIR, END - 260, 256, BigInt(0), END - 4), u32(END - 4) > 0], + random_get: w.random_get(END - 4, 4), + random_get_empty_on_last_byte: w.random_get(END - 1, 0), + }).toEqual({ + fd_fdstat_get: [WASI_ESUCCESS, 4 /* WASI_FILETYPE_REGULAR_FILE */], + fd_filestat_get: [WASI_ESUCCESS, BigInt("0123456789".length) /* st_size */], + fd_prestat_get: [WASI_ESUCCESS, "/".length], + fd_prestat_dir_name: [WASI_ESUCCESS, "/"], + args_sizes_get: [WASI_ESUCCESS, 1, "argv0\0".length], + args_get: [WASI_ESUCCESS, END - 10, "argv0\0"], + environ_sizes_get: [WASI_ESUCCESS, 1, "K=v\0".length], + environ_get: [WASI_ESUCCESS, END - 8, "K=v\0"], + clock_res_get: WASI_ESUCCESS, + clock_time_get: [WASI_ESUCCESS, true], + fd_seek: [WASI_ESUCCESS, BigInt(3)], + fd_tell: [WASI_ESUCCESS, BigInt(3)], + path_filestat_get: [WASI_ESUCCESS, BigInt("0123456789".length)], + poll_oneoff: [WASI_ESUCCESS, 1], + fd_readdir: [WASI_ESUCCESS, true], + random_get: WASI_ESUCCESS, + random_get_empty_on_last_byte: WASI_ESUCCESS, + }); + expect([w.fd_close(FILE), w.fd_close(DIR)]).toEqual([WASI_ESUCCESS, WASI_ESUCCESS]); +}); + +it("a wasm guest gets EOVERFLOW back for bad pointers instead of a host exception unwinding through it", () => { + // pointer-validation-guest.c reads argv/environ the way a libc start-up does, then + // passes pointers at the end of memory and in the top half of the address space + // (a negative i32 by the time it reaches the host) and prints the errnos. + const chunks = []; + const wasi = new WASI({ + version: "preview1", + args: ["guest", "--flag"], + env: { K: "v" }, + sendStdout: bytes => chunks.push(Buffer.from(bytes).toString()), + }); + const module = new WebAssembly.Module(fs.readFileSync(path.join(import.meta.dir, "pointer-validation-guest.wasm"))); + const instance = new WebAssembly.Instance(module, wasi.getImports(module)); + + let error; + try { + wasi.start(instance); + } catch (e) { + error = e; + } + expect({ error, stdout: chunks.join("") }).toEqual({ + error: undefined, + stdout: "args: guest --flag\nenviron: K=v\nerrnos: 61 61 61 61 61 61 61\n", + }); +});