From 07ffde8a690674edfd6fd8ab217e633bc3e3fecc Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 1 Aug 2025 20:04:16 -0700 Subject: [PATCH] Add missing check for .write() on a data-backed blob (#21552) ### What does this PR do? Add missing check for .write() on a data-backed blob ### How did you verify your code works? There is a test --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Alistair Smith --- src/bun.js/webcore/Blob.zig | 36 +++++++------ test/js/web/fetch/blob-write.test.ts | 75 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 test/js/web/fetch/blob-write.test.ts diff --git a/src/bun.js/webcore/Blob.zig b/src/bun.js/webcore/Blob.zig index 059d11e4cb..b17d48043f 100644 --- a/src/bun.js/webcore/Blob.zig +++ b/src/bun.js/webcore/Blob.zig @@ -1464,6 +1464,15 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr return writeFileWithSourceDestination(globalThis, &source_blob, &destination_blob, options); } +fn validateWritableBlob(globalThis: *jsc.JSGlobalObject, blob: *Blob) bun.JSError!void { + const store = blob.store orelse { + return globalThis.throw("Cannot write to a detached Blob", .{}); + }; + if (store.data == .bytes) { + return globalThis.throwInvalidArguments("Cannot write to a Blob backed by bytes, which are always read-only", .{}); + } +} + /// `Bun.write(destination, input, options?)` pub fn writeFile(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { const arguments = callframe.arguments(); @@ -1479,12 +1488,7 @@ pub fn writeFile(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun } // "Blob" must actually be a BunFile, not a webcore blob. if (path_or_blob == .blob) { - const store = path_or_blob.blob.store orelse { - return globalThis.throw("Cannot write to a detached Blob", .{}); - }; - if (store.data == .bytes) { - return globalThis.throwInvalidArguments("Cannot write to a Blob backed by bytes, which are always read-only", .{}); - } + try validateWritableBlob(globalThis, &path_or_blob.blob); } const data = args.nextEat() orelse { @@ -2224,6 +2228,8 @@ pub fn doWrite(this: *Blob, globalThis: *jsc.JSGlobalObject, callframe: *jsc.Cal var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments); defer args.deinit(); + try validateWritableBlob(globalThis, this); + const data = args.nextEat() orelse { return globalThis.throwInvalidArguments("blob.write(pathOrFdOrBlob, blob) expects a Blob-y thing to write", .{}); }; @@ -2275,13 +2281,14 @@ pub fn doUnlink(this: *Blob, globalThis: *jsc.JSGlobalObject, callframe: *jsc.Ca const arguments = callframe.arguments_old(1).slice(); var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments); defer args.deinit(); - const store = this.store orelse { - return jsc.JSPromise.resolvedPromiseValue(globalThis, globalThis.createInvalidArgs("Blob is detached", .{})); - }; + + try validateWritableBlob(globalThis, this); + + const store = this.store.?; return switch (store.data) { .s3 => |*s3| try s3.unlink(store, globalThis, args.nextEat()), .file => |file| file.unlink(globalThis), - else => jsc.JSPromise.resolvedPromiseValue(globalThis, globalThis.createInvalidArgs("Blob is read-only", .{})), + else => unreachable, // validateWritableBlob should have caught this }; } @@ -2569,9 +2576,9 @@ pub fn getWriter( return globalThis.throwInvalidArguments("options must be an object or undefined", .{}); } - var store = this.store orelse { - return globalThis.throwInvalidArguments("Blob is detached", .{}); - }; + try validateWritableBlob(globalThis, this); + + var store = this.store.?; if (this.isS3()) { const s3 = &this.store.?.data.s3; const path = s3.path(); @@ -2625,9 +2632,6 @@ pub fn getWriter( null, ); } - if (store.data != .file) { - return globalThis.throwInvalidArguments("Blob is read-only", .{}); - } if (Environment.isWindows) { const pathlike = store.data.file.pathlike; diff --git a/test/js/web/fetch/blob-write.test.ts b/test/js/web/fetch/blob-write.test.ts new file mode 100644 index 0000000000..89c6ff2243 --- /dev/null +++ b/test/js/web/fetch/blob-write.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { tempDirWithFiles } from "harness"; +import path from "path"; + +test("blob.write() throws for data-backed blob", () => { + const blob = new Blob(["Hello, world!"]); + expect(() => blob.write("test.txt")).toThrowErrorMatchingInlineSnapshot( + `"Cannot write to a Blob backed by bytes, which are always read-only"`, + ); +}); + +test("Bun.file(path).write() does not throw", async () => { + const file = Bun.file(path.join(tempDirWithFiles("bun-write", { a: "Hello, world!" }), "a")); + expect(() => file.write(new Blob(["Hello, world!!"]))).not.toThrow(); + expect(await file.text()).toBe("Hello, world!!"); +}); + +test("blob.unlink() throws for data-backed blob", () => { + const blob = new Blob(["Hello, world!"]); + expect(() => blob.unlink()).toThrowErrorMatchingInlineSnapshot( + `"Cannot write to a Blob backed by bytes, which are always read-only"`, + ); +}); + +test("blob.delete() throws for data-backed blob", () => { + const blob = new Blob(["Hello, world!"]); + expect(() => blob.delete()).toThrowErrorMatchingInlineSnapshot( + `"Cannot write to a Blob backed by bytes, which are always read-only"`, + ); +}); + +test("Bun.file(path).unlink() does not throw", async () => { + const dir = tempDirWithFiles("bun-unlink", { a: "Hello, world!" }); + const file = Bun.file(path.join(dir, "a")); + expect(file.unlink()).resolves.toBeUndefined(); + expect(await Bun.file(path.join(dir, "a")).exists()).toBe(false); +}); + +test("Bun.file(path).delete() does not throw", async () => { + const dir = tempDirWithFiles("bun-unlink", { a: "Hello, world!" }); + const file = Bun.file(path.join(dir, "a")); + expect(file.delete()).resolves.toBeUndefined(); + expect(await Bun.file(path.join(dir, "a")).exists()).toBe(false); +}); + +test("blob.writer() throws for data-backed blob", () => { + const blob = new Blob(["Hello, world!"]); + expect(() => blob.writer()).toThrowErrorMatchingInlineSnapshot( + `"Cannot write to a Blob backed by bytes, which are always read-only"`, + ); +}); + +test("Bun.file(path).writer() does not throw", async () => { + const dir = tempDirWithFiles("bun-writer", {}); + const file = Bun.file(path.join(dir, "test.txt")); + const writer = file.writer(); + expect(writer).toBeDefined(); + writer.write("New content"); + await writer.end(); + expect(await file.text()).toBe("New content"); +}); + +test("blob.stat() returns undefined for data-backed blob", async () => { + const blob = new Blob(["Hello, world!"]); + const stat = await blob.stat(); + expect(stat).toBeUndefined(); +}); + +test("Bun.file(path).stat() returns stats", async () => { + const dir = tempDirWithFiles("bun-stat", { a: "Hello, world!" }); + const file = Bun.file(path.join(dir, "a")); + const stat = await file.stat(); + expect(stat).toBeDefined(); + expect(stat.size).toBe(13); // "Hello, world!" is 13 bytes +});