diff --git a/src/jsc/array_buffer.rs b/src/jsc/array_buffer.rs index af27bfc598f7..77eef5786ec6 100644 --- a/src/jsc/array_buffer.rs +++ b/src/jsc/array_buffer.rs @@ -328,6 +328,29 @@ impl ArrayBuffer { } } + /// Copy this buffer's bytes into a fresh, non-shared `ArrayBuffer` via a C++ + /// memcpy, without ever forming a Rust `&[u8]` over the (possibly shared or + /// resizable) source. Use to snapshot `SharedArrayBuffer` / resizable input + /// before borrowing it as `&[u8]`, where another agent could resize or mutate + /// the backing store under the borrow. The returned `ArrayBuffer` owns the copy. + pub fn copy_to_unshared(&self, global: &JSGlobalObject) -> JsResult { + crate::mark_binding!(); + // SAFETY: FFI — same `Bun__createArrayBufferForCopy` entry point and ABI + // already used by `ArrayBuffer::create`/`create_empty`. `global` is a live + // opaque ZST handle (coerces to *const). `ptr`/`byte_len` describe `self`'s + // current live ArrayBuffer backing supplied by the caller; the C++ side + // copies those bytes immediately into a fresh, non-shared `ArrayBuffer` and + // does not retain `ptr`. A null `ptr` is only ever passed for `byte_len == 0` + // (the same empty-buffer convention as `create_empty`). No borrowed view into + // the source backing is formed or returned on this path. + let copy = crate::host_fn::from_js_host_call(global, || unsafe { + Bun__createArrayBufferForCopy(global, self.ptr.cast(), self.byte_len) + })?; + copy.as_array_buffer(global).ok_or_else(|| { + global.throw_invalid_arguments(format_args!("Failed to copy ArrayBuffer")) + }) + } + pub fn create_empty(global: &JSGlobalObject) -> JsResult { crate::mark_binding!(); match KIND { diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 182e2e58251d..7bf03d3984a6 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3413,12 +3413,17 @@ impl BlobExt for Blob { | jsc::JSType::BigInt64Array | jsc::JSType::BigUint64Array | jsc::JSType::DataView => { - return Blob::try_create( - top_value.as_array_buffer(global).unwrap().byte_slice(), - global, - false, - ) - .map_err(Into::into); + let array_buffer = top_value.as_array_buffer(global).unwrap(); + // Shared/resizable JS backing stores are not stable enough for a + // Rust `&[u8]`. Snapshot before Blob materialization; fixed + // unshared inputs keep the borrowed path. + let stable = if array_buffer.shared || array_buffer.resizable { + array_buffer.copy_to_unshared(global)? + } else { + array_buffer + }; + return Blob::try_create(stable.byte_slice(), global, false) + .map_err(Into::into); } jsc::JSType::DOMWrapper => { @@ -3611,7 +3616,13 @@ impl BlobExt for Blob { | jsc::JSType::DataView => { could_have_non_ascii = true; let buf = item.as_array_buffer(global).unwrap(); - if parts_can_run_js { + if buf.shared || buf.resizable { + // Shared/resizable backing stores are not stable + // enough for a Rust `&[u8]`; copy before + // reading the bytes into the joiner. + let snapshot = buf.copy_to_unshared(global)?; + joiner.push_cloned(snapshot.byte_slice()); + } else if parts_can_run_js { // A later part may run user JS that detaches // or resizes this buffer before `done()`. joiner.push_cloned(buf.byte_slice()); diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts index 7ee0b6ffa480..be87640f8214 100644 --- a/test/js/bun/s3/s3.test.ts +++ b/test/js/bun/s3/s3.test.ts @@ -1801,3 +1801,97 @@ describe("s3 multipart upload id validation", () => { expect(exitCode).toBe(0); }, 60_000); }); + +describe.concurrent("SharedArrayBuffer upload (stable bytes)", () => { + // S3 write accepts SharedArrayBuffer-backed upload data (the public type + // surface allows it). The byte source is snapshotted before Rust borrows it, + // so these uploads must still send exactly the requested view bytes against a + // local fake S3 endpoint — no real credentials required. + function fakeS3() { + const received: { method: string; path: string; body: Uint8Array }[] = []; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + received.push({ method: req.method, path: url.pathname, body: new Uint8Array(await req.arrayBuffer()) }); + return new Response("", { status: 200, headers: { etag: '"stable"', "content-length": "0" } }); + }, + }); + const options = { + endpoint: server.url.href, + accessKeyId: "test", + secretAccessKey: "test", + bucket: "bucket", + region: "us-east-1", + }; + return { server, options, received }; + } + + // A SAB-backed view surrounded by 0xff bytes that must never be uploaded, so a + // stale or whole-buffer read would change the asserted body. + function sabView(offset: number, bytes: number[]) { + const sab = new SharedArrayBuffer(offset + bytes.length + 4); + const full = new Uint8Array(sab); + full.fill(0xff); + full.set(bytes, offset); + return new Uint8Array(sab, offset, bytes.length); + } + + const putBody = (received: { method: string; body: Uint8Array }[]) => received.find(r => r.method === "PUT")?.body; + + it("S3File.write uploads a nonzero-offset Uint8Array(SAB) view", async () => { + const { server, options, received } = fakeS3(); + try { + await s3("file-view.bin", options).write(sabView(8, [1, 2, 3, 4, 5, 6, 7, 8])); + expect(received.some(r => r.method === "PUT" && r.path === "/bucket/file-view.bin")).toBe(true); + expect(Array.from(putBody(received)!)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + } finally { + server.stop(true); + } + }); + + it("S3Client.write uploads a nonzero-offset Uint8Array(SAB) view", async () => { + const { server, options, received } = fakeS3(); + try { + await new S3Client(options).write("client-view.bin", sabView(4, [10, 11, 12, 13])); + expect(received.some(r => r.method === "PUT" && r.path === "/bucket/client-view.bin")).toBe(true); + expect(Array.from(putBody(received)!)).toEqual([10, 11, 12, 13]); + } finally { + server.stop(true); + } + }); + + it("Bun.write(S3File, view) uploads a nonzero-offset Uint8Array(SAB) view", async () => { + const { server, options, received } = fakeS3(); + try { + await Bun.write(s3("bun-write-view.bin", options), sabView(2, [20, 21, 22])); + expect(received.some(r => r.method === "PUT" && r.path === "/bucket/bun-write-view.bin")).toBe(true); + expect(Array.from(putBody(received)!)).toEqual([20, 21, 22]); + } finally { + server.stop(true); + } + }); + + it("S3File.write accepts a raw SharedArrayBuffer", async () => { + const { server, options, received } = fakeS3(); + try { + const sab = new SharedArrayBuffer(5); + new Uint8Array(sab).set([1, 2, 3, 4, 5]); + await s3("raw-sab.bin", options).write(sab); + expect(Array.from(putBody(received)!)).toEqual([1, 2, 3, 4, 5]); + } finally { + server.stop(true); + } + }); + + it("S3File.write accepts a zero-length Uint8Array(SAB) view", async () => { + const { server, options, received } = fakeS3(); + try { + await s3("zero-view.bin", options).write(new Uint8Array(new SharedArrayBuffer(8), 4, 0)); + expect(received.some(r => r.method === "PUT" && r.path === "/bucket/zero-view.bin")).toBe(true); + expect(putBody(received)!.byteLength).toBe(0); + } finally { + server.stop(true); + } + }); +}); diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index dc5ccbd1adf9..64c50b110c76 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -556,3 +556,61 @@ describe("slice bounds are respected when streaming and serving", () => { expect(await get.text()).toBe("3456"); }); }); + +// Acceptance/behavior coverage for SharedArrayBuffer- and resizable-backed Blob +// input. The fix copies such backing in C++ before Rust borrows it as a &[u8] (a +// shared-aliasing UB that Miri catches but JS cannot observe, since Blob always +// copies its bytes synchronously); these assert the observable contract: the +// input is accepted and only the view's range is materialized. +test("Blob copies a nonzero-offset SharedArrayBuffer view", async () => { + // The 0xff guard bytes around the view must not appear in the Blob. + const sab = new SharedArrayBuffer(16); + const bytes = new Uint8Array(sab); + bytes.fill(0xff); + bytes.set([1, 2, 3, 4], 6); + + const blob = new Blob([new Uint8Array(sab, 6, 4)]); + expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([1, 2, 3, 4]); +}); + +test("Blob copies SharedArrayBuffer parts in multi-part blobs", async () => { + const sab = new SharedArrayBuffer(16); + const bytes = new Uint8Array(sab); + bytes.fill(0xff); + bytes.set([1, 2, 3, 4], 6); + + const blob = new Blob([new Uint8Array(sab, 6, 4), "x"]); + expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([1, 2, 3, 4, 120]); +}); + +test("Blob copies multiple SharedArrayBuffer parts", async () => { + const sab = new SharedArrayBuffer(16); + const bytes = new Uint8Array(sab); + bytes.fill(0xff); + bytes.set([1, 2], 4); + bytes.set([3, 4], 10); + + const blob = new Blob([new Uint8Array(sab, 4, 2), new Uint8Array(sab, 10, 2)]); + expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([1, 2, 3, 4]); +}); + +test("Blob copies DataView parts over SharedArrayBuffer", async () => { + const sab = new SharedArrayBuffer(16); + const bytes = new Uint8Array(sab); + bytes.fill(0xff); + bytes.set([5, 6, 7], 8); + + const blob = new Blob([new DataView(sab, 8, 3)]); + expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([5, 6, 7]); +}); + +test("Blob copies a resizable ArrayBuffer view", async () => { + // Resizable (non-shared) ArrayBuffer backing takes the same copy path as a SAB. + const rab = new ArrayBuffer(16, { maxByteLength: 32 }); + const bytes = new Uint8Array(rab); + bytes.fill(0xff); + bytes.set([1, 2, 3, 4], 6); + + const blob = new Blob([new Uint8Array(rab, 6, 4)]); + expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual([1, 2, 3, 4]); +});