Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3164,6 +3164,20 @@ impl BlobExt for Blob {
self.detach();
return Err(global.throw_out_of_memory());
}
// arrayBuffer()/bytes() must copy the bytes (WHATWG). The transfer
// below aliases the store's bytes, observable once the store is
// shared — Response.clone() bumps the store refcount, so mutating
// the clone's buffer would mutate the original (oven-sh/bun#22885).
// Only transfer when this blob uniquely owns the store; else copy.
if let Some(store) = self.store() {
if !store.has_one_ref() {
// SAFETY: `buf` is the store-backed view, read-only here;
// the other owner keeps the store (and `buf`) alive.
return jsc::ArrayBuffer::create::<TYPED_ARRAY_VIEW>(global, unsafe {
&*buf
});
}
}
// Move the existing +1 out. Cloning then `transfer()` would leak a ref.
let store = self.take_store().expect("transfer with null store");
// SAFETY: see `Share` arm. After `take()` the store ref is moved
Expand Down
38 changes: 38 additions & 0 deletions test/js/web/fetch/body-clone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,3 +568,41 @@ test("Blob type from a consumed Response keeps the original content-type after c
expect(stdout.trim().split("\n")).toEqual(["application/x-original-type-0000000000000001", "clone-ok", "churn-ok"]);
expect(exitCode).toBe(0);
});

// Regression for oven-sh/bun#22885: arrayBuffer()/bytes() must copy the body
// bytes (WHATWG), not hand out a view aliasing the shared store. Response.clone()
// shares the backing store, so mutating a clone's buffer leaked into the original.
// String bodies already copied; these use store-backed bodies (fetch/Blob/typed
// array), which took the zero-copy transfer path.
test("Response.clone().arrayBuffer() does not alias the original body (#22885)", async () => {
using server = Bun.serve({ port: 0, fetch: () => new Response("hello world") });
const x = await fetch(server.url);
const buffer = await x.clone().arrayBuffer();
// The clone reads the correct bytes before mutation.
expect(new TextDecoder().decode(buffer)).toBe("hello world");
// Mutating the clone's buffer must not reach back into the original body.
new Uint8Array(buffer).fill("X".charCodeAt(0));
expect(await x.text()).toBe("hello world");
});

test("Response.clone().bytes() does not alias the original body (#22885)", async () => {
const x = new Response(new Blob(["hello world"]));
const bytes = await x.clone().bytes();
expect(new TextDecoder().decode(bytes)).toBe("hello world");
bytes.fill("X".charCodeAt(0));
expect(await x.text()).toBe("hello world");
});

test("mutating one clone's buffer leaves the original and sibling clones intact (#22885)", async () => {
const x = new Response(new Uint8Array([104, 101, 108, 108, 111])); // "hello"
const first = x.clone();
const second = x.clone();
new Uint8Array(await first.arrayBuffer()).fill("X".charCodeAt(0));
expect(await second.text()).toBe("hello");
expect(await x.text()).toBe("hello");
});

test("Response.arrayBuffer() without clone still returns the body bytes (#22885 fast path)", async () => {
const x = new Response(new Uint8Array([104, 101, 108, 108, 111])); // "hello"
expect(new TextDecoder().decode(await x.arrayBuffer())).toBe("hello");
});