From ca2478312e77c2e8e4e2c0101c3c0a31592fe8e7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:39:26 +0000 Subject: [PATCH 1/2] Blob.slice: mark the slice as typed when it is given a contentType get_slice_from set content_type_was_set only when the contentType was heap-owned. A contentType that is a registered MIME string is interned, so blob.slice(0, n, "image/png") of an untyped Blob had a type and was not marked as typed. fetch() sent no Content-Type for it, a static route served none, stream() dropped the type, and an application/json slice did not import as JSON. The flag is now set for every contentType that slice() accepts, the same as the type option of the Blob constructor. --- src/runtime/webcore/Blob.rs | 4 +- test/js/web/fetch/blob.test.ts | 85 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 2710caf622ab..4e83436cc786 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -1653,7 +1653,7 @@ impl BlobExt for Blob { blob.offset.set(offset); blob.size.set(len); - let content_type_was_allocated = content_type.is_owned() && !content_type.is_empty(); + let content_type_was_given = !content_type.is_empty(); // infer the content type if it was not specified if content_type.is_empty() && !self.content_type.get().is_owned() @@ -1664,7 +1664,7 @@ impl BlobExt for Blob { blob.content_type.set(content_type); } blob.content_type_was_set - .set(self.content_type_was_set.get() || content_type_was_allocated); + .set(self.content_type_was_set.get() || content_type_was_given); blob.into_js(global_this) } diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index dc0c772681ef..0e4cdc41adca 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -696,6 +696,91 @@ describe("slice bounds are respected when streaming and serving", () => { }); }); +// A slice made with a contentType is typed like a Blob made with the constructor's +// `type` option. A registered MIME string ("image/png") is interned. Any other type +// ("IMAGE/PNG", "application/x-foo") is a heap copy. A reader of the type must not +// see which of the two it is. +describe("the type of a Blob slice", () => { + // Each place that reads whether a caller gave a Blob its type. `null` is no + // Content-Type header. Each reader gets a Blob of its own from `make`. + async function seenBy(make: () => Blob | Promise) { + await using server = Bun.serve({ + port: 0, + routes: { "/route": new Response(await make()) }, + fetch: req => Response.json(req.headers.get("content-type")), + }); + const received = async (response: Promise) => await (await response).json(); + const route = await fetch(new URL("/route", server.url)); + await route.arrayBuffer(); + return { + type: (await make()).type, + fetchBody: await received(fetch(server.url, { method: "POST", body: await make() })), + fetchBodyWithOtherHeaders: await received( + fetch(server.url, { method: "POST", body: await make(), headers: { "x-other": "1" } }), + ), + requestBody: await received(fetch(new Request(server.url, { method: "POST", body: await make() }))), + staticRoute: route.headers.get("content-type"), + stream: (await new Response((await make()).stream()).blob()).type, + clone: await received(fetch(server.url, { method: "POST", body: structuredClone(await make()) })), + responseBlob: (await new Response(await make()).blob()).type, + }; + } + + // A multipart file with no Content-Type line. Its type comes from its name, not + // from a caller. + async function formDataFile() { + const body = '--x\r\nContent-Disposition: form-data; name="file"; filename="a.txt"\r\n\r\nabc\r\n--x--\r\n'; + const headers = { "content-type": "multipart/form-data; boundary=x" }; + return (await new Response(body, { headers }).formData()).get("file") as File; + } + + test.each([ + ["new Blob(parts).slice(0, 2, type)", (type: string) => new Blob(["abc"]).slice(0, 2, type)], + ["new Blob(parts).slice(type)", (type: string) => (new Blob(["ab"]) as Bun.BunFile).slice(type)], + ["new File(parts, name).slice(0, 2, type)", (type: string) => new File(["abc"], "a.bin").slice(0, 2, type)], + ["formData.get(name).slice(0, 2, type)", async (type: string) => (await formDataFile()).slice(0, 2, type)], + ])("%s is typed like new Blob(parts, { type })", async (_label, slice) => { + const sliced: Record = {}; + const constructed: Record = {}; + for (const type of ["image/png", "IMAGE/PNG", "application/x-foo"]) { + sliced[type] = await seenBy(() => slice(type)); + constructed[type] = await seenBy(() => new Blob(["ab"], { type })); + } + expect(sliced).toEqual(constructed); + // Fetch sends the type of a Blob body as its Content-Type. + expect(sliced).toMatchObject({ + "image/png": { type: "image/png", fetchBody: "image/png" }, + "IMAGE/PNG": { type: "image/png", fetchBody: "image/png" }, + "application/x-foo": { type: "application/x-foo", fetchBody: "application/x-foo" }, + }); + }); + + test("an empty slice is typed the same by both spellings of its contentType", async () => { + const interned = await seenBy(() => new Blob(["abc"]).slice(1, 1, "image/png")); + const heap = await seenBy(() => new Blob(["abc"]).slice(1, 1, "IMAGE/PNG")); + expect(interned).toEqual(heap); + expect(interned).toMatchObject({ type: "image/png", staticRoute: "image/png" }); + }); + + test("a slice with no contentType keeps an interned parent type, and who gave it", async () => { + const ofConstructed = await seenBy(() => new Blob(["abc"], { type: "image/png" }).slice(0, 2)); + const ofSlice = await seenBy(() => new Blob(["abcd"]).slice(0, 3, "image/png").slice(0, 2)); + expect(ofSlice).toEqual(ofConstructed); + expect(ofSlice).toMatchObject({ type: "image/png", fetchBody: "image/png" }); + // Nobody gave this file its type, and nobody gave its slice one. + expect(await seenBy(async () => (await formDataFile()).slice(0, 3))).toEqual(await seenBy(formDataFile)); + }); + + test("the object URL of a slice typed application/json imports as JSON", async () => { + const url = URL.createObjectURL(new Blob(['{"a":1} and more']).slice(0, 7, "application/json")); + try { + expect((await import(url)).default).toEqual({ a: 1 }); + } finally { + URL.revokeObjectURL(url); + } + }); +}); + // A slice shares its parent's backing store. Once the parent and the slice // objects have been collected, a stream made from the slice holds the only // reference to that store, and the buffered consumers (Bun.readableStreamTo*, From f800f5bfe9f7a3e5f95a7ab77d839c8d57be60e5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:07:14 +0000 Subject: [PATCH 2/2] Blob.slice: give the slice of an empty Blob its contentType BlobExt::slice returned an untyped empty Blob for a receiver of size 0 before it read any argument. It now reads the arguments first, and get_slice_from builds the result from an empty parent. The slice of an empty Blob is typed like the slice of a Blob with bytes. The contentType is one expression, so the default is not stored and copied. BlobContentType::from_lowercased no longer allocates for an empty type. --- src/jsc/webcore_types.rs | 5 +- src/runtime/webcore/Blob.rs | 19 ++- test/js/web/fetch/blob.test.ts | 243 ++++++++++++++++++++++++++++++++- 3 files changed, 257 insertions(+), 10 deletions(-) diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 57c11091e05c..5cb29a62e05e 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -120,9 +120,12 @@ impl BlobContentType { ) } - /// Heap-owned ASCII-lowercased copy of `slice`. + /// ASCII-lowercased copy of `slice`, heap-owned unless `slice` is empty. #[inline] pub fn from_lowercased(slice: &[u8]) -> Self { + if slice.is_empty() { + return Self::default(); + } let mut buf = vec![0u8; slice.len()]; bun_core::strings::copy_lowercase(slice, &mut buf); Self::Owned(std::sync::Arc::from(buf)) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 4e83436cc786..6e75ee331ed2 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -1679,9 +1679,7 @@ impl BlobExt for Blob { ) -> JsResult { let global_this = cx.global(); - if self.size.get() == 0 { - return Ok(Blob::init_empty(global_this).into_js(global_this)); - } + let receiver_is_empty = self.size.get() == 0; let size_i64 = i64::try_from(self.size.get()).unwrap_or(i64::MAX); @@ -1715,14 +1713,23 @@ impl BlobExt for Blob { } } - let mut blob_content_type = BlobContentType::default(); - if content_type.is_string() { + let blob_content_type = if content_type.is_string() { let content_type_view = content_type.to_js_string_view(global_this)?; let slicer = content_type_view.to_utf8(); let slice = slicer.slice(); if is_valid_blob_type(slice) { - blob_content_type = BlobContentType::from_script(slice); + BlobContentType::from_script(slice) + } else { + BlobContentType::default() } + } else { + BlobContentType::default() + }; + + // Nothing of an empty receiver is kept: the slice is what `new Blob([], { type })` gives. + if receiver_is_empty { + let parent = Blob::init_empty(global_this); + return Ok(parent.get_slice_from(global_this, 0, 0, blob_content_type)); } Ok(self.get_slice_from(global_this, relative_start, relative_end, blob_content_type)) diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 0e4cdc41adca..ed81776f099d 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -1,8 +1,9 @@ -import { estimateShallowMemoryUsageOf } from "bun:jsc"; -import { describe, expect, test } from "bun:test"; +import { estimateShallowMemoryUsageOf, serialize } from "bun:jsc"; +import { describe, expect, jest, test } from "bun:test"; import { bunEnv, bunExe, expectRssDeltaBelow, gcTick, isASAN, tempDir } from "harness"; import type { BlobPropertyBag } from "node:buffer"; import type { BinaryLike } from "node:crypto"; +import { openAsBlob } from "node:fs"; import path from "node:path"; test("blob: imports have sourcemapped stacktraces", async () => { @@ -718,7 +719,7 @@ describe("the type of a Blob slice", () => { fetchBodyWithOtherHeaders: await received( fetch(server.url, { method: "POST", body: await make(), headers: { "x-other": "1" } }), ), - requestBody: await received(fetch(new Request(server.url, { method: "POST", body: await make() }))), + requestBody: await received(fetch(new Request(server.url.href, { method: "POST", body: await make() }))), staticRoute: route.headers.get("content-type"), stream: (await new Response((await make()).stream()).blob()).type, clone: await received(fetch(server.url, { method: "POST", body: structuredClone(await make()) })), @@ -779,6 +780,242 @@ describe("the type of a Blob slice", () => { URL.revokeObjectURL(url); } }); + + // The receiver of each slice below has a size of 0. Its slice keeps nothing of it. + describe("of an empty Blob", () => { + // A body of 0 bytes sends no Content-Type, so the static route is the reader that tells. + test.each([ + ["new Blob([]).slice(0, 0, type)", (type: string) => new Blob([]).slice(0, 0, type)], + ["new Blob([]).slice(type)", (type: string) => (new Blob([]) as Bun.BunFile).slice(type)], + ["new File([], name).slice(0, 0, type)", (type: string) => new File([], "a.bin").slice(0, 0, type)], + ])("%s is typed like new Blob([], { type })", async (_label, slice) => { + const sliced: Record = {}; + const constructed: Record = {}; + for (const type of ["image/png", "IMAGE/PNG", "application/x-foo"]) { + sliced[type] = await seenBy(() => slice(type)); + constructed[type] = await seenBy(() => new Blob([], { type })); + } + expect(sliced).toEqual(constructed); + expect(sliced).toMatchObject({ + "image/png": { type: "image/png", staticRoute: "image/png" }, + "IMAGE/PNG": { type: "image/png", staticRoute: "image/png" }, + "application/x-foo": { type: "application/x-foo", staticRoute: "application/x-foo" }, + }); + }); + + test("a contentType that is absent, empty or not valid gives no type", async () => { + const typedParent = () => new Blob([], { type: "image/png" }); + const untyped = await seenBy(() => new Blob([])); + expect({ + "slice()": await seenBy(() => typedParent().slice()), + 'slice(0, 0, "")': await seenBy(() => typedParent().slice(0, 0, "")), + "slice(0, 0, notValid)": await seenBy(() => typedParent().slice(0, 0, "te\x09xt/plain")), + }).toEqual({ "slice()": untyped, 'slice(0, 0, "")': untyped, "slice(0, 0, notValid)": untyped }); + expect(untyped).toMatchObject({ type: "", staticRoute: null }); + }); + + const typedSlices = (empty: Blob) => ({ + "slice(0, 0, type)": empty.slice(0, 0, "x/custom").type, + // Bun's own overloads. Node.js reads a string in these positions as a number. + "slice(type)": (empty as Bun.BunFile).slice("x/custom").type, + "slice(begin, type)": (empty as Bun.BunFile).slice(0, "x/custom").type, + "uppercase type": empty.slice(0, 0, "X/Custom").type, + "registered type": empty.slice(0, 0, "image/png").type, + }); + const typed = { + "slice(0, 0, type)": "x/custom", + "slice(type)": "x/custom", + "slice(begin, type)": "x/custom", + "uppercase type": "x/custom", + "registered type": "image/png", + }; + const serialized = (blob: Blob) => Buffer.from(serialize(blob)).toString("hex"); + + const inMemory: [string, () => Blob | Promise][] = [ + ["new Blob()", () => new Blob()], + ["new Blob([])", () => new Blob([])], + ['new Blob([""])', () => new Blob([""])], + ["new Blob([new Uint8Array(0)])", () => new Blob([new Uint8Array(0)])], + ["new Blob([], { type })", () => new Blob([], { type: "text/plain" })], + ["an empty slice of a Blob", () => new Blob(["abcdef"]).slice(6, 6)], + ["an empty slice of a Blob, with a type", () => new Blob(["abcdef"]).slice(6, 6, "text/plain")], + ["new File([], name)", () => new File([], "n.txt")], + ["new File([], name, { type })", () => new File([], "n.txt", { type: "text/plain" })], + ["structuredClone(new Blob([]))", () => structuredClone(new Blob([]))], + ['new Response("").blob()', () => new Response("").blob()], + [ + "a File of 0 bytes from FormData", + () => { + const form = new FormData(); + form.append("file", new File([], "n.txt")); + return form.get("file") as File; + }, + ], + ]; + + test.each(inMemory)("%s", async (_, make) => { + const empty = await make(); + const type = empty.type; + expect(empty.size).toBe(0); + expect(typedSlices(empty)).toEqual(typed); + // Unlike a Blob with bytes, an empty Blob does not pass a registered type on to its slice. + expect({ + "slice()": empty.slice().type, + "slice(0, 0)": empty.slice(0, 0).type, + "slice(0, 0, undefined)": empty.slice(0, 0, undefined).type, + "empty type": empty.slice(0, 0, "").type, + "type with a character that is not allowed": empty.slice(0, 0, "te\x09xt/plain").type, + }).toEqual({ + "slice()": "", + "slice(0, 0)": "", + "slice(0, 0, undefined)": "", + "empty type": "", + "type with a character that is not allowed": "", + }); + // The slice is the Blob that the constructor makes, with the mark that a caller gave the type. + expect({ + "slice()": serialized(empty.slice()), + "empty type": serialized(empty.slice(0, 0, "")), + "type with a character that is not allowed": serialized(empty.slice(0, 0, "te\x09xt/plain")), + "x/custom": serialized(empty.slice(0, 0, "x/custom")), + "image/png": serialized(empty.slice(0, 0, "image/png")), + }).toEqual({ + "slice()": serialized(new Blob([])), + "empty type": serialized(new Blob([])), + "type with a character that is not allowed": serialized(new Blob([])), + "x/custom": serialized(new Blob([], { type: "x/custom" })), + "image/png": serialized(new Blob([], { type: "image/png" })), + }); + expect(empty.type).toBe(type); + }); + + const storeBacked: [string, (dir: string) => Blob | Promise][] = [ + ["Bun.file() before anything reads its size", dir => Bun.file(path.join(dir, "empty.bin"))], + [ + "Bun.file() after .size", + dir => { + const file = Bun.file(path.join(dir, "empty.bin")); + expect(file.size).toBe(0); + return file; + }, + ], + [ + "Bun.file() after exists()", + async dir => { + const file = Bun.file(path.join(dir, "empty.bin")); + expect(await file.exists()).toBe(true); + return file; + }, + ], + [ + "fs.openAsBlob() after .size", + async dir => { + const blob = await openAsBlob(path.join(dir, "empty.bin")); + expect(blob.size).toBe(0); + return blob; + }, + ], + [ + "a Bun.build() output of 0 bytes", + async dir => { + const { outputs } = await Bun.build({ + entrypoints: [path.join(dir, "entry.js")], + loader: { ".bin": "file" }, + }); + return outputs.find(output => output.kind === "asset")!; + }, + ], + [ + "an empty slice of an S3 file", + () => + new Bun.S3Client({ accessKeyId: "id", secretAccessKey: "secret", bucket: "bucket" }).file("key").slice(0, 0), + ], + ]; + + test.each(storeBacked)("%s", async (_, make) => { + using dir = tempDir("blob-slice-empty", { + "empty.bin": "", + "entry.js": `import asset from "./empty.bin"; console.log(asset);`, + }); + const empty = await make(String(dir)); + expect(typedSlices(empty)).toEqual(typed); + expect(empty.size).toBe(0); + }); + + test("the slice of an empty File is a Blob with no name", () => { + const slice = new File([], "n.txt", { type: "text/plain" }).slice(0, 0, "x/custom"); + expect({ isFile: slice instanceof File, name: (slice as Partial).name, type: slice.type }).toEqual({ + isFile: false, + name: undefined, + type: "x/custom", + }); + }); + + test("the type reaches a Response, a Request, FormData and structuredClone", async () => { + const empty = new Blob([]); + const slice = empty.slice(0, 0, "x/custom"); + const other = empty.slice(0, 0, "x/other"); + const form = new FormData(); + form.append("file", slice, "a.bin"); + expect({ + slice: slice.type, + other: other.type, + response: new Response(slice).headers.get("content-type"), + request: new Request("http://localhost/", { method: "POST", body: slice }).headers.get("content-type"), + formData: (form.get("file") as File).type, + structuredClone: structuredClone(slice).type, + text: await slice.text(), + }).toEqual({ + slice: "x/custom", + other: "x/other", + response: "x/custom", + request: "x/custom", + formData: "x/custom", + structuredClone: "x/custom", + text: "", + }); + }); + + // A String object counts as a string. Its toString runs, as it does for a Blob with bytes. + test.each([ + ["new Blob([])", () => new Blob([])], + ["new File([], name)", () => new File([], "n.txt")], + ["an empty slice of a Blob", () => new Blob(["abcdef"]).slice(6, 6)], + ["a Blob with bytes", () => new Blob(["abcdef"])], + ])("%s converts a String object that is given as the contentType", (_, make) => { + const error = new RangeError("toString failed"); + const toString = jest.fn(() => "X/Custom"); + const converted = Object.assign(new String("x/ignored"), { toString }) as unknown as string; + const throwing = Object.assign(new String("x/ignored"), { + toString() { + throw error; + }, + }) as unknown as string; + const blob = make(); + expect(blob.slice(0, 0, converted).type).toBe("x/custom"); + expect(toString).toHaveBeenCalledTimes(1); + let thrown: unknown; + try { + blob.slice(0, 0, throwing); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBe(error); + }); + + test("the size of the receiver is read before its arguments are converted", () => { + using dir = tempDir("blob-slice-empty", { "empty.bin": "" }); + const file = Bun.file(path.join(String(dir), "empty.bin")); + // Nothing has read the size of the file when slice is called. The conversion of the type reads it. + const type = Object.assign(new String("x/ignored"), { toString: () => (file.size, "x/custom") }); + const slice = file.slice(0, 0, type as unknown as string); + expect({ size: file.size, type: slice.type, name: slice.name }).toEqual({ + size: 0, + type: "x/custom", + name: file.name, + }); + }); + }); }); // A slice shares its parent's backing store. Once the parent and the slice