diff --git a/packages/preview3-shim/lib/nodejs/filesystem/descriptor.js b/packages/preview3-shim/lib/nodejs/filesystem/descriptor.js index 042f718fc..5bb6a581c 100644 --- a/packages/preview3-shim/lib/nodejs/filesystem/descriptor.js +++ b/packages/preview3-shim/lib/nodejs/filesystem/descriptor.js @@ -131,7 +131,7 @@ class Descriptor { * @throws {FSError} `payload.tag` contains mapped WASI error code. */ async writeViaStream(data, offset) { - this.#ensureHandle(); + this.#ensureWritable(data); const stream = readableByteStreamFromReader(data, { name: "file write data" }); try { @@ -154,7 +154,7 @@ class Descriptor { * @throws {FSError} `payload.tag` contains mapped WASI error code. */ async appendViaStream(data) { - this.#ensureHandle(); + this.#ensureWritable(data); const offset = await this.#handle.stat().then((s) => s.size); return this.writeViaStream(data, offset); } @@ -310,7 +310,8 @@ class Descriptor { * @throws {FSError} `payload.tag` contains mapped WASI error code. */ readDirectory() { - if (!this.#fullPath) { + const fullPath = this.#fullPath ?? this.#hostPreopen; + if (!fullPath) { throw new FSError("invalid"); } @@ -321,7 +322,7 @@ class Descriptor { .run( { op: "readDir", - fullPath: this.#fullPath, + fullPath, stream: transform.writable, preopens, }, @@ -928,6 +929,15 @@ class Descriptor { throw new FSError("bad-descriptor"); } } + + #ensureWritable(data) { + this.#ensureHandle(); + if (!this.#mode.write) { + data?.[symbolDispose]?.(); + data?.close?.(); + throw new FSError("bad-descriptor"); + } + } } const descriptorCreatePreopen = Descriptor._createPreopen; diff --git a/packages/preview3-shim/lib/nodejs/workers/filesystem-worker.js b/packages/preview3-shim/lib/nodejs/workers/filesystem-worker.js index 46ddaefea..6998ca0ef 100644 --- a/packages/preview3-shim/lib/nodejs/workers/filesystem-worker.js +++ b/packages/preview3-shim/lib/nodejs/workers/filesystem-worker.js @@ -13,6 +13,7 @@ const { opendir } = fs.promises; const CHUNK_BYTES = 64 * 1024; // Reuse a single buffer for all read/write ops const BUFFER = Buffer.alloc(CHUNK_BYTES); +const MAX_SAFE_FILE_POSITION = BigInt(Number.MAX_SAFE_INTEGER); /** Auto‐dispatch all ops */ Router().op("read", handleRead).op("write", handleWrite).op("readDir", handleReadDir); @@ -32,7 +33,7 @@ async function handleRead({ fd, offset, stream }) { const start = pos; while (true) { - const { bytesRead } = await readAsync(fd, BUFFER, 0, CHUNK_BYTES, pos); + const { bytesRead } = await readAsync(fd, BUFFER, 0, CHUNK_BYTES, filePosition(pos)); if (bytesRead === 0) { break; } @@ -70,7 +71,7 @@ async function handleWrite({ fd, offset, stream }) { break; } const buf = Buffer.from(value); - const { bytesWritten } = await writeAsync(fd, buf, 0, buf.length, pos); + const { bytesWritten } = await writeAsync(fd, buf, 0, buf.length, filePosition(pos)); pos += BigInt(bytesWritten); } return { bytesWritten: pos - start }; @@ -80,6 +81,13 @@ async function handleWrite({ fd, offset, stream }) { } } +function filePosition(position) { + if (position < 0n || position > MAX_SAFE_FILE_POSITION) { + throw new FSError("invalid"); + } + return Number(position); +} + async function handleReadDir({ fullPath, stream, preopens }) { if (!stream || typeof stream.getWriter !== "function") { throw new TypeError("stream must have a getWriter() method"); diff --git a/packages/preview3-shim/test/filesystem.test.js b/packages/preview3-shim/test/filesystem.test.js index 2d49dee28..7315fae25 100644 --- a/packages/preview3-shim/test/filesystem.test.js +++ b/packages/preview3-shim/test/filesystem.test.js @@ -71,6 +71,73 @@ describe("Descriptor with os.tmpdir()", () => { child[Symbol.dispose]?.(); }); + test("writeViaStream accepts non zero offsets", async () => { + const sub = `${relBase}/file-write-offset.txt`; + const child = await rootDescriptor.openAt( + {}, + sub, + { create: true }, + { read: true, write: true }, + ); + + const { tx, rx } = stream(); + await tx.write("Hello, World!"); + await tx.close(); + await child.writeViaStream(rx, 5n); + + const [sr, fr] = child.readViaStream(0n); + const buf = await sr.readAll(); + await fr.read(); + + expect(buf).toEqual(Buffer.from("\0\0\0\0\0Hello, World!")); + + child[Symbol.dispose]?.(); + }); + + test("readViaStream accepts non zero offsets", async () => { + const sub = `${relBase}/file-read-offset.txt`; + const child = await rootDescriptor.openAt( + {}, + sub, + { create: true }, + { read: true, write: true }, + ); + + const { tx, rx } = stream(); + await tx.write("skip-read"); + await tx.close(); + await child.writeViaStream(rx, 5n); + + const [sr, fr] = child.readViaStream(5n); + const buf = await sr.readAll(); + await fr.read(); + + expect(new TextDecoder().decode(buf)).toBe("skip-read"); + + child[Symbol.dispose]?.(); + }); + + test("writeViaStream rejects read-only descriptors before consuming data", async () => { + const sub = `${relBase}/file-read-only-write.txt`; + const writable = await rootDescriptor.openAt( + {}, + sub, + { create: true }, + { read: true, write: true }, + ); + writable[Symbol.dispose]?.(); + + const readOnly = await rootDescriptor.openAt({}, sub, {}, { read: true }); + const reader = { + read: vi.fn(async () => Uint8Array.of(1)), + }; + + await expect(readOnly.writeViaStream(reader, 0n)).rejects.toThrow(); + expect(reader.read).not.toHaveBeenCalled(); + + readOnly[Symbol.dispose]?.(); + }); + test("appendViaStream <=> readViaStream round-trip", async () => { const sub = `${relBase}/file-append.txt`; const child = await rootDescriptor.openAt( @@ -131,6 +198,27 @@ describe("Descriptor with os.tmpdir()", () => { dirDesc[Symbol.dispose]?.(); }); + test("readDirectory works on preopen descriptors", async () => { + const entryName = "preopen-entry.txt"; + await fs.writeFile(path.join(tmpDir, entryName), ""); + + const virtualPath = `/preview3-test-${path.basename(tmpDir)}`; + filesystem._addPreopen(virtualPath, tmpDir); + const [preopen] = filesystem.preopens + .getDirectories() + .find(([_desc, path]) => path === virtualPath); + + const [stream, future] = preopen.readDirectory(); + const entries = []; + let entry; + while ((entry = await stream.read()) !== null) { + entries.push(entry); + } + await future.read(); + + expect(entries.some((e) => e.name === entryName)).toBe(true); + }); + test("isSameObject & metadataHash vs metadataHashAt", async () => { const sub = `${relBase}/file3.txt`; const child = await rootDescriptor.openAt(