From 779052f8cb46f71222e5314e1765fae481688523 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:51:56 +0000 Subject: [PATCH 1/2] Blob: stringify null/undefined parts, accept iterables, coerce slice() args null and undefined entries in a blobParts array were silently dropped, so the same code produced different bytes in Bun than in Node and browsers (the File API converts non-Blob/BufferSource parts with ToString). The single-element fast path already stringified them, so Bun disagreed with itself too. - Stop skipping null/undefined in the multi-part loop; they stringify to "null"/"undefined" like every other non-Blob/BufferSource part, and array holes behave the same. - new Blob(null) and new File(null, name) throw a TypeError; undefined still means the optional argument was omitted. - Accept any iterable object (Set, Map, generators) as blobParts by materializing it into a JSArray with the JS iteration protocol (JSValue::to_array_from_iterable); the array keeps generator-produced values rooted. - Blob.slice(): provided, non-undefined start/end go through ToNumber (null, booleans, objects with valueOf; symbols throw) and a non-string contentType goes through ToString. The documented Bun overloads slice(contentType) / slice(begin, contentType) and new Blob(typedArray) are unchanged. --- src/jsc/JSValue.rs | 38 ++++++ src/runtime/webcore/Blob.rs | 112 +++++++++++------- .../js/web/fetch/blob-array-fast-path.test.ts | 3 +- test/js/web/fetch/blob.test.ts | 87 +++++++++++++- 4 files changed, 197 insertions(+), 43 deletions(-) diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index d2e90883f26..33310796531 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2463,6 +2463,44 @@ impl JSValue { ) -> JsResult<()> { self.for_each(global, ctx, callback) } + /// Materialize an iterable into a new `JSArray` by running the JS + /// iteration protocol. The returned array keeps the yielded values + /// GC-reachable (values from a generator have no other root). + /// Iteration runs user JS and may throw. + pub fn to_array_from_iterable(self, global: &JSGlobalObject) -> JsResult { + struct Ctx { + array: JSValue, + index: u32, + error: Option, + } + extern "C" fn append( + _: *mut crate::VM, + global: &JSGlobalObject, + ctx: *mut c_void, + item: JSValue, + ) { + // SAFETY: `ctx` is the stack `Ctx` passed to `for_each` below; it + // outlives the iteration. + let ctx = unsafe { &mut *ctx.cast::() }; + if ctx.error.is_some() { + return; + } + match ctx.array.put_index(global, ctx.index, item) { + Ok(()) => ctx.index += 1, + Err(err) => ctx.error = Some(err), + } + } + let mut ctx = Ctx { + array: crate::JSArray::create_empty(global, 0)?, + index: 0, + error: None, + }; + self.for_each(global, std::ptr::from_mut(&mut ctx).cast(), append)?; + if let Some(err) = ctx.error { + return Err(err); + } + Ok(ctx.array) + } /// `JSValue.forEachProperty` — enumerate own props, /// invoking `callback` per (key, value, is_symbol, is_private_symbol). /// diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index fc5508d0c53..dbe129d32fd 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -2052,9 +2052,12 @@ impl BlobExt for Blob { // If the optional start parameter is not used as a parameter, let relativeStart be 0. let mut relative_start: i64 = 0; // If the optional end parameter is not used, let relativeEnd be size. - let mut relative_end: i64 = i64::try_from(self.size.get()).expect("int cast"); + let size = i64::try_from(self.size.get()).expect("int cast"); + let mut relative_end: i64 = size; - // Mutate the fixed-3 args array in place to shift the string arg into [2]. + // Bun extension (documented on `BunFile`/`S3File`): a string in the + // `start`/`end` position is the `contentType`, as in `slice(type)` + // and `slice(begin, type)`. Shift it into args[2] and clear its slot. if args[0].is_string() { args[2] = args[0]; args[1] = JSValue::ZERO; @@ -2064,48 +2067,59 @@ impl BlobExt for Blob { args[1] = JSValue::ZERO; } + // WebIDL `[Clamp] long long`: every provided, non-`undefined` value + // goes through ToNumber (`null` becomes 0, objects go through + // `valueOf`, symbols throw); NaN becomes 0. Absent and `undefined` + // keep the spec default. + fn relative_index( + value: JSValue, + global: &JSGlobalObject, + size: i64, + default: i64, + ) -> JsResult { + if value.is_empty() || value.is_undefined() { + return Ok(default); + } + let int = if value.is_number() { + value.to_int64() + } else { + let number = value.to_number(global)?; + // Saturating truncation, mirroring `JSValue::to_int64`. + if number.is_nan() { 0 } else { number as i64 } + }; + Ok(if int < 0 { + int.wrapping_add(size).max(0) + } else { + int.min(size) + }) + } + let mut args_iter = jsc::ArgumentsSlice::init(global_this.bun_vm(), &arguments_.ptr[..3]); if let Some(start_) = args_iter.next_eat() { - if start_.is_number() { - let start = start_.to_int64(); - if start < 0 { - relative_start = (start - .wrapping_add(i64::try_from(self.size.get()).expect("int cast"))) - .max(0); - } else { - relative_start = start.min(i64::try_from(self.size.get()).expect("int cast")); - } - } + relative_start = relative_index(start_, global_this, size, relative_start)?; } if let Some(end_) = args_iter.next_eat() { - if end_.is_number() { - let end = end_.to_int64(); - if end < 0 { - relative_end = (end - .wrapping_add(i64::try_from(self.size.get()).expect("int cast"))) - .max(0); - } else { - relative_end = end.min(i64::try_from(self.size.get()).expect("int cast")); - } - } + relative_end = relative_index(end_, global_this, size, relative_end)?; } let mut content_type = BlobContentType::default(); if let Some(content_type_) = args_iter.next_eat() { 'inner: { - if content_type_.is_string() { - let zig_str = content_type_.get_zig_string(global_this)?; - let slicer = zig_str.to_slice(); - let slice = slicer.slice(); - if !is_valid_blob_type(slice) { - break 'inner; - } - content_type = match global_this.bun_vm().as_mut().mime_type(slice) { - Some(mime) => BlobContentType::from(mime), - None => BlobContentType::from_lowercased(slice), - }; + // WebIDL `DOMString`: stringify any provided, non-`undefined` + // value (numbers included), not just string primitives. + if content_type_.is_empty() || content_type_.is_undefined() { + break 'inner; } + let content_type_str = content_type_.to_slice(global_this)?; + let slice = content_type_str.slice(); + if !is_valid_blob_type(slice) { + break 'inner; + } + content_type = match global_this.bun_vm().as_mut().mime_type(slice) { + Some(mime) => BlobContentType::from(mime), + None => BlobContentType::from_lowercased(slice), + }; } } @@ -3304,6 +3318,14 @@ impl BlobExt for Blob { ) -> JsResult { let mut current = arg; if current.is_undefined_or_null() { + // `new Blob(undefined)` omits the optional `blobParts` argument, but + // `null` is not convertible to a sequence (TypeError in the spec, + // Node, and browsers). + if REQUIRE_ARRAY && current.is_null() { + return Err( + global.throw_invalid_arguments(format_args!("new Blob() expects an Array")) + ); + } return Ok(Blob::init_empty(global)); } @@ -3311,6 +3333,10 @@ impl BlobExt for Blob { let might_only_be_one_thing: bool; arg.ensure_still_alive(); let _keep = jsc::EnsureStillAlive(arg); + // Set when a non-array iterable argument is materialized into a JSArray + // below: that array is the only GC root for values yielded by a + // generator, so it must stay alive until `joiner.done()`. + let mut _keep_iterable_parts = jsc::EnsureStillAlive(JSValue::UNDEFINED); let mut fail_if_top_value_is_not_typed_array_like = false; match current.js_type_loose() { jsc::JSType::Array | jsc::JSType::DerivedArray => { @@ -3441,9 +3467,16 @@ impl BlobExt for Blob { // new Blob("ok") // new File("ok", "file.txt") if fail_if_top_value_is_not_typed_array_like { - return Err( - global.throw_invalid_arguments(format_args!("new Blob() expects an Array")) - ); + // WebIDL `sequence` accepts any iterable object + // (Set, Map, generators, ...): materialize it into a JS array + // and join it like one below. Non-iterables still throw. + if !top_value.is_object() || !top_value.is_iterable(global)? { + return Err( + global.throw_invalid_arguments(format_args!("new Blob() expects an Array")) + ); + } + current = top_value.to_array_from_iterable(global)?; + _keep_iterable_parts = jsc::EnsureStillAlive(current); } } @@ -3513,11 +3546,10 @@ impl BlobExt for Blob { } while let Some(item) = iter.next()? { - if item.is_undefined_or_null() { - continue; - } - { + // `null`/`undefined` parts are non-cells (`JSType::Cell` + // here) and stringify to "null"/"undefined" below, per + // WebIDL's ToString on non-Blob/BufferSource parts. match item.js_type_loose() { jsc::JSType::NumberObject | jsc::JSType::Cell diff --git a/test/js/web/fetch/blob-array-fast-path.test.ts b/test/js/web/fetch/blob-array-fast-path.test.ts index a071f324c9a..e3af6102ec0 100644 --- a/test/js/web/fetch/blob-array-fast-path.test.ts +++ b/test/js/web/fetch/blob-array-fast-path.test.ts @@ -43,7 +43,8 @@ describe("JSArrayIterator butterfly fast path", () => { arr[0] = "first"; arr[100] = "last"; const blob = new Blob(arr); - expect(await blob.text()).toBe("firstlast"); + // holes read as `undefined`, which stringifies (same as Node and browsers) + expect(await blob.text()).toBe("first" + "undefined".repeat(99) + "last"); }); test("hole + Array.prototype indexed getter consults prototype (slow path)", async () => { diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index d7091915b68..11b976440c0 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -56,9 +56,10 @@ for (const info of [ expect(blob.slice(0, Infinity).size).toBe(blob.size); expect(blob.slice(-Infinity).size).toBe(blob.size); expect(blob.slice(0, NaN).size).toBe(0); + // `start`/`end` go through ToNumber, so values it rejects throw like in Node and browsers // @ts-expect-error - expect(blob.slice(Symbol(), "-123").size).toBe(6); - expect(blob.slice(Object.create(null), "-123").size).toBe(6); + expect(() => blob.slice(Symbol(), "-123")).toThrow(TypeError); + expect(() => blob.slice(Object.create(null), "-123")).toThrow(TypeError); // @ts-expect-error expect(blob.slice(null, "-123").size).toBe(6); expect(blob.slice(0, 10).size).toBe(blob.size); @@ -112,6 +113,88 @@ test("new Blob stringifies non-Blob object parts in order", async () => { expect(await new Blob(["a", ["x", "y"], "b"]).text()).toBe("ax,yb"); }); +test("new Blob stringifies null and undefined parts instead of dropping them", async () => { + expect(await new Blob([null] as any[]).text()).toBe("null"); + expect(await new Blob([undefined] as any[]).text()).toBe("undefined"); + expect(await new Blob(["a", null, "b", undefined] as any[]).text()).toBe("anullbundefined"); + // a hole reads as `undefined` + expect(await new Blob([, "x"] as any[]).text()).toBe("undefinedx"); + + const blob = new Blob([null, undefined, 1, {}, true] as any[]); + expect(await blob.text()).toBe("nullundefined1[object Object]true"); + expect(blob.size).toBe(33); + + expect(await new File(["a", null] as any[], "f.txt").text()).toBe("anull"); +}); + +test("new Blob(null) throws and new Blob(undefined) is an empty Blob", () => { + // null is not convertible to a sequence + // @ts-expect-error + expect(() => new Blob(null)).toThrow(TypeError); + // @ts-expect-error + expect(() => new File(null, "f.txt")).toThrow(TypeError); + // undefined means the optional argument was omitted + expect(new Blob(undefined).size).toBe(0); + expect(new Blob().size).toBe(0); +}); + +test("new Blob accepts any iterable of parts", async () => { + expect(await new Blob(new Set(["a", "b"]) as any).text()).toBe("ab"); + expect(await new Blob(new Map([["a", "b"]]) as any).text()).toBe("a,b"); + expect(new Blob(new Set() as any).size).toBe(0); + expect(await new Blob(new Set([new Blob(["inner"]), "!"]) as any).text()).toBe("inner!"); + expect(await new File(new Set(["a"]) as any, "f.txt").text()).toBe("a"); + + function* parts() { + yield "x"; + yield null; + yield 3; + yield new Uint8Array([33]); + } + expect(await new Blob(parts() as any).text()).toBe("xnull3!"); + + // values produced by a generator are only reachable through the constructor + function* garbage() { + for (let i = 0; i < 16; i++) { + yield `chunk${i};`; + Bun.gc(true); + } + } + expect(await new Blob(garbage() as any).text()).toBe(Array.from({ length: 16 }, (_, i) => `chunk${i};`).join("")); + + // errors thrown while iterating propagate + function* throws() { + yield "x"; + throw new RangeError("mid-iteration"); + } + expect(() => new Blob(throws() as any)).toThrow(RangeError); + + // non-iterable objects and primitives are still rejected + expect(() => new Blob({} as any)).toThrow(TypeError); + expect(() => new Blob({ length: 1, 0: "a" } as any)).toThrow(TypeError); + expect(() => new Blob(123 as any)).toThrow(TypeError); + expect(() => new Blob("ab" as any)).toThrow(TypeError); +}); + +test("Blob.slice applies ToNumber to start/end and ToString to contentType", async () => { + const blob = new Blob(["hello world"]); // 11 bytes + // null is provided, so it coerces to 0 instead of being ignored + expect(blob.slice(null as any, null as any).size).toBe(0); + expect(blob.slice(0, null as any).size).toBe(0); + expect(await blob.slice(null as any, 3).text()).toBe("hel"); + // objects coerce through valueOf + expect(await blob.slice({ valueOf: () => 2 } as any, 4).text()).toBe("ll"); + expect(await blob.slice(false as any, true as any).text()).toBe("h"); + // undefined still means "not provided" + expect(blob.slice(undefined, undefined).size).toBe(11); + expect(blob.slice(undefined, undefined, undefined).type).toBe(""); + // a non-string contentType is stringified + expect(blob.slice(1, 3, 123 as any).type).toBe("123"); + expect(blob.slice(1, 3, { toString: () => "text/x" } as any).type).toBe("text/x"); + // values ToNumber rejects throw + expect(() => blob.slice(Symbol() as any)).toThrow(TypeError); +}); + test("blob: can be fetched", async () => { const blob = new Blob(["Bun", "Foo"]); const url = URL.createObjectURL(blob); From eb5c074173a639636f19cc3efe6aa06dd1e74647 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:18:07 +0000 Subject: [PATCH 2/2] Blob.slice: convert arguments before the empty-blob fast path WebIDL converts arguments before the operation body, so ToNumber on start/end (including its side effects and exceptions) and ToString on contentType must run even when the source Blob is empty, and a provided contentType applies to the returned empty Blob. --- src/runtime/webcore/Blob.rs | 22 +++++++++++++++------- test/js/web/fetch/blob.test.ts | 9 +++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index dbe129d32fd..a17ba45af0d 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -2042,13 +2042,6 @@ impl BlobExt for Blob { // index the full fixed-3 array (args[2] is written below regardless of len). let args = &mut arguments_.ptr[..]; - if self.size.get() == 0 { - let ptr = Blob::new(Blob::init_empty(global_this)); - // SAFETY: `ptr` just came from `heap::alloc` in `Blob::new`; force - // the inherent `Blob::to_js(&mut self)` over `JsClass::to_js`. - return Ok(unsafe { BlobExt::to_js(&*ptr, global_this) }); - } - // If the optional start parameter is not used as a parameter, let relativeStart be 0. let mut relative_start: i64 = 0; // If the optional end parameter is not used, let relativeEnd be size. @@ -2123,6 +2116,21 @@ impl BlobExt for Blob { } } + // WebIDL converts every argument before the operation body runs, so + // the coercions above (and their side effects and exceptions) happen + // even when this Blob is empty; the provided type still applies. + if self.size.get() == 0 { + let blob = Blob::init_empty(global_this); + let content_type_was_allocated = content_type.is_owned() && !content_type.is_empty(); + blob.content_type.set(content_type); + blob.content_type_was_set + .set(self.content_type_was_set.get() || content_type_was_allocated); + let ptr = Blob::new(blob); + // SAFETY: `ptr` just came from `heap::alloc` in `Blob::new`; force + // the inherent `Blob::to_js(&mut self)` over `JsClass::to_js`. + return Ok(unsafe { BlobExt::to_js(&*ptr, global_this) }); + } + Ok(self.get_slice_from(global_this, relative_start, relative_end, content_type)) } diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 11b976440c0..0f81b35ddc6 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -193,6 +193,15 @@ test("Blob.slice applies ToNumber to start/end and ToString to contentType", asy expect(blob.slice(1, 3, { toString: () => "text/x" } as any).type).toBe("text/x"); // values ToNumber rejects throw expect(() => blob.slice(Symbol() as any)).toThrow(TypeError); + + // arguments are converted before the empty-blob fast path returns + const empty = new Blob([]); + expect(() => empty.slice(Symbol() as any)).toThrow(TypeError); + let valueOfCalls = 0; + expect(empty.slice({ valueOf: () => (valueOfCalls++, 0) } as any).size).toBe(0); + expect(valueOfCalls).toBe(1); + expect(empty.slice(0, 0, 123 as any).type).toBe("123"); + expect(empty.slice(0, 0, "text/HTML").type).toBe("text/html"); }); test("blob: can be fetched", async () => {