Skip to content

Commit ea05fe7

Browse files
author
=
committed
fix(p2-shim): correct read/write semantics
1 parent bb56a3e commit ea05fe7

2 files changed

Lines changed: 254 additions & 1 deletion

File tree

packages/preview2-shim/lib/nodejs/filesystem.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ class Descriptor {
8181

8282
static _createPreopen(hostPreopen) {
8383
const descriptor = new Descriptor();
84+
descriptor.#mode = {
85+
read: true,
86+
write: true,
87+
mutateDirectory: true,
88+
};
8489
descriptor.#hostPreopen = hostPreopen.endsWith("/")
8590
? hostPreopen.slice(0, -1) || "/"
8691
: hostPreopen;
@@ -353,6 +358,25 @@ class Descriptor {
353358
if (preopenEntries.length === 0) {
354359
throw "access";
355360
}
361+
// https://github.com/WebAssembly/WASI/blob/b7ee9febdcc0652aef5aeaf80dc329d240eb84e8/proposals/filesystem/wit/types.wit#L532-L534
362+
// > If `flags` contains `descriptor-flags::mutate-directory`, and the base
363+
// > descriptor doesn't have `descriptor-flags::mutate-directory` set,
364+
// > `open-at` fails with `error-code::read-only`.
365+
if (descriptorFlags.mutateDirectory && !this.#mode?.mutateDirectory) {
366+
throw "read-only";
367+
}
368+
// https://github.com/WebAssembly/WASI/blob/b7ee9febdcc0652aef5aeaf80dc329d240eb84e8/proposals/filesystem/wit/types.wit#L536-L539
369+
// > If `flags` contains `write` or `mutate-directory`, or `open-flags`
370+
// > contains `truncate` or `create`, and the base descriptor doesn't have
371+
// > `descriptor-flags::mutate-directory` set, `open-at` fails with
372+
// > `error-code::read-only`.
373+
if(
374+
!this.#mode?.mutateDirectory
375+
&&
376+
(descriptorFlags.write || descriptorFlags.mutateDirectory || openFlags.truncate || openFlags.create)
377+
) {
378+
throw "read-only";
379+
}
356380
const fullPath = this.#getFullPath(path, pathFlags.symlinkFollow);
357381
let fsOpenFlags = 0x0;
358382
if (openFlags.create) {
@@ -383,7 +407,7 @@ class Descriptor {
383407
if (!pathFlags.symlinkFollow) {
384408
fsOpenFlags |= constants.O_NOFOLLOW;
385409
}
386-
if (descriptorFlags.requestedWriteSync || descriptorFlags.mutateDirectory) {
410+
if (descriptorFlags.requestedWriteSync) {
387411
throw "unsupported";
388412
}
389413
// Currently throw to match Wasmtime

packages/preview2-shim/test/test.js

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,235 @@ suite("Sandboxing", () => {
894894
}),
895895
);
896896
});
897+
suite("FS openAt descriptor flags", () => {
898+
test("preopen descriptor has read, write, and mutateDirectory flags", async () => {
899+
const { filesystem } = await import("@bytecodealliance/preview2-shim");
900+
const [[rootDescriptor]] = filesystem.preopens.getDirectories();
901+
const flags = rootDescriptor.getFlags();
902+
assert.strictEqual(flags.read, true);
903+
assert.strictEqual(flags.write, true);
904+
assert.strictEqual(flags.mutateDirectory, true);
905+
});
906+
907+
test(
908+
"openAt with mutateDirectory descriptor flag succeeds on preopen",
909+
testWithGCWrap(async () => {
910+
const { mkdtemp, mkdir } = await import("node:fs/promises");
911+
const { tmpdir } = await import("node:os");
912+
const { sep, normalize } = await import("node:path");
913+
const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation");
914+
915+
const tmpDir = await mkdtemp(normalize(tmpdir() + sep));
916+
await mkdir(`${tmpDir}/subdir`);
917+
918+
const shim = new WASIShim({
919+
sandbox: { preopens: { "/work": tmpDir } },
920+
});
921+
const importObj = shim.getImportObject();
922+
const [[rootDescriptor]] = importObj["wasi:filesystem/preopens"].getDirectories();
923+
924+
const dirDescriptor = rootDescriptor.openAt(
925+
{},
926+
"subdir",
927+
{ directory: true },
928+
{ read: true, mutateDirectory: true },
929+
);
930+
dirDescriptor[symbolDispose]();
931+
}),
932+
);
933+
934+
test(
935+
"openAt with create and write flags succeeds on preopen",
936+
testWithGCWrap(async () => {
937+
const { mkdtemp } = await import("node:fs/promises");
938+
const { tmpdir } = await import("node:os");
939+
const { sep, normalize } = await import("node:path");
940+
const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation");
941+
942+
const tmpDir = await mkdtemp(normalize(tmpdir() + sep));
943+
944+
const shim = new WASIShim({
945+
sandbox: { preopens: { "/work": tmpDir } },
946+
});
947+
const importObj = shim.getImportObject();
948+
const [[rootDescriptor]] = importObj["wasi:filesystem/preopens"].getDirectories();
949+
950+
const fd = rootDescriptor.openAt(
951+
{},
952+
"test-file.txt",
953+
{ create: true },
954+
{ write: true },
955+
);
956+
fd[symbolDispose]();
957+
}),
958+
);
959+
960+
test(
961+
"openAt read-only on read-only base descriptor succeeds",
962+
testWithGCWrap(async () => {
963+
const { mkdtemp, mkdir, writeFile } = await import("node:fs/promises");
964+
const { tmpdir } = await import("node:os");
965+
const { sep, normalize } = await import("node:path");
966+
const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation");
967+
968+
const tmpDir = await mkdtemp(normalize(tmpdir() + sep));
969+
await mkdir(`${tmpDir}/subdir`);
970+
await writeFile(`${tmpDir}/subdir/existing.txt`, "hello");
971+
972+
const shim = new WASIShim({
973+
sandbox: { preopens: { "/work": tmpDir } },
974+
});
975+
const importObj = shim.getImportObject();
976+
const [[rootDescriptor]] = importObj["wasi:filesystem/preopens"].getDirectories();
977+
978+
const readOnlyDir = rootDescriptor.openAt(
979+
{},
980+
"subdir",
981+
{ directory: true },
982+
{ read: true },
983+
);
984+
985+
const fd = readOnlyDir.openAt({}, "existing.txt", {}, { read: true });
986+
fd[symbolDispose]();
987+
readOnlyDir[symbolDispose]();
988+
}),
989+
);
990+
991+
test(
992+
"openAt throws read-only when base lacks mutateDirectory and write requested",
993+
testWithGCWrap(async () => {
994+
const { mkdtemp, mkdir, writeFile } = await import("node:fs/promises");
995+
const { tmpdir } = await import("node:os");
996+
const { sep, normalize } = await import("node:path");
997+
const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation");
998+
999+
const tmpDir = await mkdtemp(normalize(tmpdir() + sep));
1000+
await mkdir(`${tmpDir}/subdir`);
1001+
await writeFile(`${tmpDir}/subdir/existing.txt`, "hello");
1002+
1003+
const shim = new WASIShim({
1004+
sandbox: { preopens: { "/work": tmpDir } },
1005+
});
1006+
const importObj = shim.getImportObject();
1007+
const [[rootDescriptor]] = importObj["wasi:filesystem/preopens"].getDirectories();
1008+
1009+
const readOnlyDir = rootDescriptor.openAt(
1010+
{},
1011+
"subdir",
1012+
{ directory: true },
1013+
{ read: true },
1014+
);
1015+
1016+
throws(
1017+
() => readOnlyDir.openAt({}, "existing.txt", {}, { write: true }),
1018+
(err) => err === "read-only",
1019+
);
1020+
1021+
readOnlyDir[symbolDispose]();
1022+
}),
1023+
);
1024+
1025+
test(
1026+
"openAt throws read-only when base lacks mutateDirectory and create requested",
1027+
testWithGCWrap(async () => {
1028+
const { mkdtemp, mkdir } = await import("node:fs/promises");
1029+
const { tmpdir } = await import("node:os");
1030+
const { sep, normalize } = await import("node:path");
1031+
const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation");
1032+
1033+
const tmpDir = await mkdtemp(normalize(tmpdir() + sep));
1034+
await mkdir(`${tmpDir}/subdir`);
1035+
1036+
const shim = new WASIShim({
1037+
sandbox: { preopens: { "/work": tmpDir } },
1038+
});
1039+
const importObj = shim.getImportObject();
1040+
const [[rootDescriptor]] = importObj["wasi:filesystem/preopens"].getDirectories();
1041+
1042+
const readOnlyDir = rootDescriptor.openAt(
1043+
{},
1044+
"subdir",
1045+
{ directory: true },
1046+
{ read: true },
1047+
);
1048+
1049+
throws(
1050+
() => readOnlyDir.openAt({}, "new-file.txt", { create: true }, { read: true }),
1051+
(err) => err === "read-only",
1052+
);
1053+
1054+
readOnlyDir[symbolDispose]();
1055+
}),
1056+
);
1057+
1058+
test(
1059+
"openAt throws read-only when base lacks mutateDirectory and truncate requested",
1060+
testWithGCWrap(async () => {
1061+
const { mkdtemp, mkdir, writeFile } = await import("node:fs/promises");
1062+
const { tmpdir } = await import("node:os");
1063+
const { sep, normalize } = await import("node:path");
1064+
const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation");
1065+
1066+
const tmpDir = await mkdtemp(normalize(tmpdir() + sep));
1067+
await mkdir(`${tmpDir}/subdir`);
1068+
await writeFile(`${tmpDir}/subdir/existing.txt`, "hello");
1069+
1070+
const shim = new WASIShim({
1071+
sandbox: { preopens: { "/work": tmpDir } },
1072+
});
1073+
const importObj = shim.getImportObject();
1074+
const [[rootDescriptor]] = importObj["wasi:filesystem/preopens"].getDirectories();
1075+
1076+
const readOnlyDir = rootDescriptor.openAt(
1077+
{},
1078+
"subdir",
1079+
{ directory: true },
1080+
{ read: true },
1081+
);
1082+
1083+
throws(
1084+
() => readOnlyDir.openAt({}, "existing.txt", { truncate: true }, { write: true }),
1085+
(err) => err === "read-only",
1086+
);
1087+
1088+
readOnlyDir[symbolDispose]();
1089+
}),
1090+
);
1091+
1092+
test(
1093+
"openAt throws read-only when base lacks mutateDirectory and mutateDirectory requested",
1094+
testWithGCWrap(async () => {
1095+
const { mkdtemp, mkdir } = await import("node:fs/promises");
1096+
const { tmpdir } = await import("node:os");
1097+
const { sep, normalize } = await import("node:path");
1098+
const { WASIShim } = await import("@bytecodealliance/preview2-shim/instantiation");
1099+
1100+
const tmpDir = await mkdtemp(normalize(tmpdir() + sep));
1101+
await mkdir(`${tmpDir}/subdir`);
1102+
1103+
const shim = new WASIShim({
1104+
sandbox: { preopens: { "/work": tmpDir } },
1105+
});
1106+
const importObj = shim.getImportObject();
1107+
const [[rootDescriptor]] = importObj["wasi:filesystem/preopens"].getDirectories();
1108+
1109+
const readOnlyDir = rootDescriptor.openAt(
1110+
{},
1111+
"subdir",
1112+
{ directory: true },
1113+
{ read: true },
1114+
);
1115+
1116+
throws(
1117+
() => readOnlyDir.openAt({}, "subdir", { directory: true }, { read: true, mutateDirectory: true }),
1118+
(err) => err === "read-only",
1119+
);
1120+
1121+
readOnlyDir[symbolDispose]();
1122+
}),
1123+
);
1124+
});
1125+
8971126
suite("Browser shim guards", () => {
8981127
test("pollList throws on empty list", async () => {
8991128
const { poll } = await import("../lib/browser/io.js");

0 commit comments

Comments
 (0)