diff --git a/packages/preview2-shim/lib/nodejs/filesystem.js b/packages/preview2-shim/lib/nodejs/filesystem.js index ef1f504cc..97572949c 100644 --- a/packages/preview2-shim/lib/nodejs/filesystem.js +++ b/packages/preview2-shim/lib/nodejs/filesystem.js @@ -81,6 +81,11 @@ class Descriptor { static _createPreopen(hostPreopen) { const descriptor = new Descriptor(); + descriptor.#mode = { + read: true, + write: true, + mutateDirectory: true, + }; descriptor.#hostPreopen = hostPreopen.endsWith("/") ? hostPreopen.slice(0, -1) || "/" : hostPreopen; @@ -353,6 +358,27 @@ class Descriptor { if (preopenEntries.length === 0) { throw "access"; } + // https://github.com/WebAssembly/WASI/blob/b7ee9febdcc0652aef5aeaf80dc329d240eb84e8/proposals/filesystem/wit/types.wit#L532-L534 + // > If `flags` contains `descriptor-flags::mutate-directory`, and the base + // > descriptor doesn't have `descriptor-flags::mutate-directory` set, + // > `open-at` fails with `error-code::read-only`. + if (descriptorFlags.mutateDirectory && !this.#mode?.mutateDirectory) { + throw "read-only"; + } + // https://github.com/WebAssembly/WASI/blob/b7ee9febdcc0652aef5aeaf80dc329d240eb84e8/proposals/filesystem/wit/types.wit#L536-L539 + // > If `flags` contains `write` or `mutate-directory`, or `open-flags` + // > contains `truncate` or `create`, and the base descriptor doesn't have + // > `descriptor-flags::mutate-directory` set, `open-at` fails with + // > `error-code::read-only`. + if ( + !this.#mode?.mutateDirectory && + (descriptorFlags.write || + descriptorFlags.mutateDirectory || + openFlags.truncate || + openFlags.create) + ) { + throw "read-only"; + } const fullPath = this.#getFullPath(path, pathFlags.symlinkFollow); let fsOpenFlags = 0x0; if (openFlags.create) { @@ -383,7 +409,7 @@ class Descriptor { if (!pathFlags.symlinkFollow) { fsOpenFlags |= constants.O_NOFOLLOW; } - if (descriptorFlags.requestedWriteSync || descriptorFlags.mutateDirectory) { + if (descriptorFlags.requestedWriteSync) { throw "unsupported"; } // Currently throw to match Wasmtime diff --git a/packages/preview2-shim/test/test.js b/packages/preview2-shim/test/test.js index 3d218453c..536b4bc0e 100644 --- a/packages/preview2-shim/test/test.js +++ b/packages/preview2-shim/test/test.js @@ -894,6 +894,121 @@ suite("Sandboxing", () => { }), ); }); +suite("FS openAt descriptor flags", () => { + async function createTmpPreopen(setup) { + const { mkdtemp, mkdir, writeFile } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { sep, normalize } = await import("node:path"); + const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation"); + + const tmpDir = await mkdtemp(normalize(tmpdir() + sep)); + if (setup) { + await setup({ tmpDir, mkdir, writeFile }); + } + + const shim = new WASIShim({ + sandbox: { preopens: { "/work": tmpDir } }, + }); + const importObj = shim.getImportObject(); + const [[rootDescriptor]] = importObj["wasi:filesystem/preopens"].getDirectories(); + return rootDescriptor; + } + + test("preopen descriptor has read, write, and mutateDirectory flags", async () => { + const { filesystem } = await import("@bytecodealliance/preview2-shim"); + const [[rootDescriptor]] = filesystem.preopens.getDirectories(); + const flags = rootDescriptor.getFlags(); + assert.strictEqual(flags.read, true); + assert.strictEqual(flags.write, true); + assert.strictEqual(flags.mutateDirectory, true); + }); + + test( + "openAt with mutateDirectory descriptor flag succeeds on preopen", + testWithGCWrap(async () => { + const root = await createTmpPreopen(({ tmpDir, mkdir }) => mkdir(`${tmpDir}/subdir`)); + const fd = root.openAt( + {}, + "subdir", + { directory: true }, + { read: true, mutateDirectory: true }, + ); + fd[symbolDispose](); + }), + ); + + test( + "openAt with create and write flags succeeds on preopen", + testWithGCWrap(async () => { + const root = await createTmpPreopen(); + const fd = root.openAt({}, "test-file.txt", { create: true }, { write: true }); + fd[symbolDispose](); + }), + ); + + suite("on read-only base descriptor", () => { + const readOnlySetup = ({ tmpDir, mkdir, writeFile }) => + mkdir(`${tmpDir}/subdir`).then(() => writeFile(`${tmpDir}/subdir/existing.txt`, "hello")); + + [ + { + name: "read succeeds", + path: "existing.txt", + openFlags: {}, + descriptorFlags: { read: true }, + }, + { + name: "write throws read-only", + path: "existing.txt", + openFlags: {}, + descriptorFlags: { write: true }, + error: "read-only", + }, + { + name: "create throws read-only", + path: "new-file.txt", + openFlags: { create: true }, + descriptorFlags: { read: true }, + error: "read-only", + }, + { + name: "truncate throws read-only", + path: "existing.txt", + openFlags: { truncate: true }, + descriptorFlags: { write: true }, + error: "read-only", + }, + { + name: "mutateDirectory throws read-only", + path: "x", + openFlags: { directory: true }, + descriptorFlags: { read: true, mutateDirectory: true }, + error: "read-only", + }, + ].forEach(({ name, path, openFlags, descriptorFlags, error }) => { + test( + name, + testWithGCWrap(async () => { + const root = await createTmpPreopen(readOnlySetup); + const readOnlyDir = root.openAt({}, "subdir", { directory: true }, { read: true }); + + if (error) { + throws( + () => readOnlyDir.openAt({}, path, openFlags, descriptorFlags), + (err) => err === error, + ); + } else { + const fd = readOnlyDir.openAt({}, path, openFlags, descriptorFlags); + fd[symbolDispose](); + } + + readOnlyDir[symbolDispose](); + }), + ); + }); + }); +}); + suite("Browser shim guards", () => { test("pollList throws on empty list", async () => { const { poll } = await import("../lib/browser/io.js");