diff --git a/src/js/node/wasi.ts b/src/js/node/wasi.ts index a0ae57df5c5c..178e250a88dc 100644 --- a/src/js/node/wasi.ts +++ b/src/js/node/wasi.ts @@ -1437,7 +1437,7 @@ var require_wasi = __commonJS({ path_open: wrap( (dirfd, _dirflags, pathPtr, pathLen, oflags, fsRightsBase, fsRightsInheriting, fsFlags, fdPtr) => { try { - CHECK_FD(dirfd, constants_1.WASI_RIGHT_PATH_OPEN); + const stats = CHECK_FD(dirfd, constants_1.WASI_RIGHT_PATH_OPEN); fsRightsBase = BigInt(fsRightsBase); fsRightsInheriting = BigInt(fsRightsInheriting); const read = @@ -1512,7 +1512,7 @@ var require_wasi = __commonJS({ if (p.startsWith("proc/")) { throw new types_1.WASIError(constants_1.WASI_EBADF); } - const fullUnresolved = path.resolve(p); + const fullUnresolved = stats.path ? path.resolve(stats.path, p) : path.resolve(p); let full; try { full = fs.realpathSync(fullUnresolved); diff --git a/test/js/bun/wasm/preopen-wasi.c b/test/js/bun/wasm/preopen-wasi.c new file mode 100644 index 000000000000..bcf1be9d2313 --- /dev/null +++ b/test/js/bun/wasm/preopen-wasi.c @@ -0,0 +1,89 @@ +// Minimal WASI program that opens a file from a preopen using path_open +// and reads it, then writes the contents to another file. +// +// We declare the WASI imports by hand so we don't depend on a WASI sysroot. +// +// Compile: +// clang --target=wasm32 -nostdlib -O2 -fno-builtin \ +// -Wl,--no-entry -Wl,--export=_start -Wl,--export=memory \ +// -Wl,--allow-undefined \ +// -o preopen-wasi.wasm preopen-wasi.c +// +// Regression fixture for oven-sh/bun#30302. + +typedef unsigned int u32; +typedef unsigned long long u64; + +__attribute__((import_module("wasi_snapshot_preview1"), import_name("path_open"))) +unsigned int wasi_path_open(u32 dirfd, u32 dirflags, const char *path, u32 path_len, + u32 oflags, u64 fs_rights_base, u64 fs_rights_inheriting, + u32 fdflags, u32 *opened_fd); + +__attribute__((import_module("wasi_snapshot_preview1"), import_name("fd_read"))) +unsigned int wasi_fd_read(u32 fd, const void *iovs, u32 iovs_len, u32 *nread); + +__attribute__((import_module("wasi_snapshot_preview1"), import_name("fd_write"))) +unsigned int wasi_fd_write(u32 fd, const void *iovs, u32 iovs_len, u32 *nwritten); + +__attribute__((import_module("wasi_snapshot_preview1"), import_name("fd_close"))) +unsigned int wasi_fd_close(u32 fd); + +__attribute__((import_module("wasi_snapshot_preview1"), import_name("proc_exit"))) +void wasi_proc_exit(u32 rval) __attribute__((noreturn)); + +struct ciovec { + const void *buf; + u32 buf_len; +}; + +// WASI rights for read/write files. Using the superset the Bun implementation +// grants to preopen directories keeps things simple. +#define RIGHTS_ALL ((u64)-1) + +// O_CREAT | O_TRUNC in WASI oflags +#define WASI_O_CREAT (1 << 0) +#define WASI_O_TRUNC (1 << 3) + +static char read_buf[256]; +static char out_buf[256]; + +void _start() { + // Preopen dirfd is always 3 (first after stdin/stdout/stderr). + u32 dirfd = 3; + + // Read "input.txt" from the preopen. + u32 in_fd = 0; + unsigned int err = wasi_path_open(dirfd, 0, "input.txt", 9, 0, + RIGHTS_ALL, RIGHTS_ALL, 0, &in_fd); + if (err != 0) wasi_proc_exit(10 + err); + + struct ciovec read_iov = { read_buf, sizeof(read_buf) }; + u32 nread = 0; + err = wasi_fd_read(in_fd, &read_iov, 1, &nread); + if (err != 0) wasi_proc_exit(30 + err); + wasi_fd_close(in_fd); + + // Write "output.txt" in the preopen with "got: " prefix + contents. + u32 out_fd = 0; + err = wasi_path_open(dirfd, 0, "output.txt", 10, + WASI_O_CREAT | WASI_O_TRUNC, + RIGHTS_ALL, RIGHTS_ALL, 0, &out_fd); + if (err != 0) wasi_proc_exit(50 + err); + + // Build "got: " in out_buf. + const char *prefix = "got: "; + u32 plen = 5; + for (u32 i = 0; i < plen; i++) out_buf[i] = prefix[i]; + for (u32 i = 0; i < nread && plen + i < sizeof(out_buf); i++) { + out_buf[plen + i] = read_buf[i]; + } + u32 total = plen + nread; + + struct ciovec write_iov = { out_buf, total }; + u32 nwritten = 0; + err = wasi_fd_write(out_fd, &write_iov, 1, &nwritten); + if (err != 0) wasi_proc_exit(70 + err); + + wasi_fd_close(out_fd); + wasi_proc_exit(0); +} diff --git a/test/js/bun/wasm/preopen-wasi.wasm b/test/js/bun/wasm/preopen-wasi.wasm new file mode 100755 index 000000000000..455e881a4a68 Binary files /dev/null and b/test/js/bun/wasm/preopen-wasi.wasm differ diff --git a/test/js/bun/wasm/wasi.test.js b/test/js/bun/wasm/wasi.test.js index 5e6994a50457..48a628e7b312 100644 --- a/test/js/bun/wasm/wasi.test.js +++ b/test/js/bun/wasm/wasi.test.js @@ -1,6 +1,7 @@ import { spawnSync } from "bun"; import { expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; it("Should support printing 'hello world'", () => { const { stdout, stderr, exitCode } = spawnSync({ @@ -20,3 +21,62 @@ it("Should support printing 'hello world'", () => { exitCode: 0, }); }); + +// node:wasi path_open must resolve the guest path against the preopen's +// mapped host directory, not against process.cwd(). Every other path_* +// handler in src/js/node/wasi.ts does this; path_open used to call +// path.resolve(p) (no base), making a WASM program that path_opens an +// entry under its preopen fail with ENOENT at cwd. +// Regression guard for oven-sh/bun#30302. +it("node:wasi path_open resolves against the preopen host dir, not cwd", async () => { + using dir = tempDir("wasi-preopen", { + "work/input.txt": "hello from host file", + // Deliberately place a wrong-looking file at `cwd/input.txt` so that + // the buggy cwd-relative lookup would pick this up instead of erroring + // — catches a regression that silently opens the wrong file. + "input.txt": "wrong file — should never be read", + "runner.mjs": ` + import fs from "node:fs"; + import { WASI } from "node:wasi"; + + const workDir = process.argv[2]; + const wasmPath = process.argv[3]; + const wasi = new WASI({ + version: "preview1", + preopens: { "/work": workDir }, + }); + const wasmBytes = fs.readFileSync(wasmPath); + const module = await WebAssembly.compile(wasmBytes); + const instance = await WebAssembly.instantiate(module, wasi.getImports(module)); + try { + wasi.start(instance); + } catch (err) { + process.stderr.write("wasi.start threw: " + (err?.message ?? err) + "\\n"); + process.exit(2); + } + `, + }); + + const cwd = String(dir); + const workDir = join(cwd, "work"); + const wasmPath = join(import.meta.dir, "preopen-wasi.wasm"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "runner.mjs", workDir, wasmPath], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + // The WASM program proc_exits(0) on success; non-zero encodes which step + // failed. See test/js/bun/wasm/preopen-wasi.c. + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + // The preopen points at /work, so the output file must land there, + // with "got: " prefixed to the host-dir input's contents. + expect(await Bun.file(join(workDir, "output.txt")).text()).toBe("got: hello from host file"); +});