From a67c923684b3dc5be40f3da6fb5639234d878986 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:42:32 +0000 Subject: [PATCH 1/5] Throw DataCloneError when structured-cloning an S3File The Blob serializer tagged an S3 store as Bytes while writing the File shaped body for it, so structuredClone(), bun:jsc/node:v8 serialize() and postMessage() of an S3File produced a record that failed to read back with "Unable to deserialize data." (postMessage lost the message and threw on the receiver instead). An S3 store is bound to the credentials and options of the client that made it, which the record has no form for, so refuse it at serialize time with a DataCloneError DOMException. The store kind to wire tag mapping is now an exhaustive match shared by the tag and the body, Store::serialize becomes File::serialize (its S3 arm was the other half of this bug and its Bytes arm was unreachable), and the deserializer rejects a File record naming an s3:// path instead of minting an S3File under the receiver's environment credentials, since no serializer writes one. --- docs/runtime/s3.mdx | 2 + src/runtime/webcore/Blob.rs | 71 ++++--- src/runtime/webcore/blob/Store.rs | 76 +++----- .../js/web/structured-clone-blob-file.test.ts | 178 +++++++++++++++++- 4 files changed, 256 insertions(+), 71 deletions(-) diff --git a/docs/runtime/s3.mdx b/docs/runtime/s3.mdx index d2293961874d..278997036bba 100644 --- a/docs/runtime/s3.mdx +++ b/docs/runtime/s3.mdx @@ -599,6 +599,8 @@ Like `Bun.file()`, `S3File` extends [`Blob`](https://developer.mozilla.org/en-US That means `S3File` instances work with `fetch()`, `Response`, and other web APIs that accept `Blob` instances. +The exception is structured cloning. An `S3File` is bound to the credentials and options of the client that created it, so `structuredClone()` and `postMessage()` throw a `DataCloneError` instead of cloning it. To use a file from a worker, send the key and call `file()` on a client in the worker. + ### Partial reads with `slice` To read a partial range of a file, use the `slice` method. diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 006cf841e312..401b16aab80e 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -176,11 +176,16 @@ pub trait BlobExt { global: &JSGlobalObject, ); fn get_content_type(&self) -> Option; - fn _on_structured_clone_serialize(&self, writer: &mut W) - -> crate::Result<()>; + /// `store_tag` is `structured_clone_store_tag(self)`, which is `None` + /// (nothing to write) for the stores the record cannot describe. + fn _on_structured_clone_serialize( + &self, + store_tag: store::SerializeTag, + writer: &mut W, + ) -> crate::Result<()>; fn on_structured_clone_serialize( &self, - _global_this: &JSGlobalObject, + global_this: &JSGlobalObject, ctx: *mut c_void, write_bytes: crate::generated_classes::WriteBytesFn, ); @@ -738,13 +743,10 @@ impl BlobExt for Blob { } fn _on_structured_clone_serialize( &self, + store_tag: store::SerializeTag, writer: &mut W, ) -> crate::Result<()> { - let is_memory_backed = if let Some(store) = self.store.get() { - matches!(store.data, store::Data::Bytes(_)) - } else { - false - }; + let is_memory_backed = matches!(store_tag, store::SerializeTag::Bytes); writer.write_int_le::(SERIALIZATION_VERSION)?; writer.write_int_le::(if is_memory_backed { @@ -758,16 +760,6 @@ impl BlobExt for Blob { writer.write_all(ct)?; writer.write_int_le::(self.content_type_was_set.get() as u8)?; - let store_tag: store::SerializeTag = if let Some(store) = self.store.get() { - if matches!(store.data, store::Data::File(_)) { - store::SerializeTag::File - } else { - store::SerializeTag::Bytes - } - } else { - store::SerializeTag::Empty - }; - writer.write_int_le::(store_tag as u8)?; if let Some(store) = self.store.get() { @@ -785,7 +777,9 @@ impl BlobExt for Blob { // on the wire and the receiver stats it locally, like v3. writer.write_int_le::(self.size.get())?; self.resolve_size(); - store.serialize(writer)?; + // Borrowed only after resolve_size(), which stats the file and + // writes the result into this same store. + store.data.as_file().serialize(writer)?; } } @@ -808,15 +802,26 @@ impl BlobExt for Blob { fn on_structured_clone_serialize( &self, - _global_this: &JSGlobalObject, + global_this: &JSGlobalObject, ctx: *mut c_void, write_bytes: crate::generated_classes::WriteBytesFn, ) { + let Some(store_tag) = structured_clone_store_tag(self) else { + // CloneSerializer checks for a pending exception after this hook + // returns and fails the whole clone with it. + let _ = global_this.throw_dom_exception( + bun_jsc::DOMExceptionCode::DataCloneError, + format_args!( + "S3File cannot be cloned: it is bound to the credentials and options of the S3Client that created it. Pass the key instead and open it again with s3.file() where it is needed." + ), + ); + return; + }; let mut writer = StructuredCloneWriter { ctx, impl_: write_bytes, }; - let _ = self._on_structured_clone_serialize(&mut writer); + let _ = self._on_structured_clone_serialize(store_tag, &mut writer); } fn on_structured_clone_transfer( @@ -4029,6 +4034,20 @@ impl bun_io::Write for StructuredCloneWriter { } } +/// `None` for an S3 store: the record has no form for the credentials and +/// options of the S3Client it is bound to, and a clone that reopened the key +/// under whatever the receiver's environment holds would not be a clone. +fn structured_clone_store_tag(blob: &Blob) -> Option { + let Some(store) = blob.store() else { + return Some(store::SerializeTag::Empty); + }; + match store.data.tag() { + store::DataTag::Bytes => Some(store::SerializeTag::Bytes), + store::DataTag::File => Some(store::SerializeTag::File), + store::DataTag::S3 => None, + } +} + // Only ever called with f64 (Blob.last_modified). A concrete impl // because Rust forbids `[u8; size_of::()]` // without `generic_const_exprs`. Bit-cast → native-endian bytes. @@ -4135,7 +4154,7 @@ fn on_structured_clone_deserialize>( break 'file Blob::new(Blob::find_or_create_file_from_path( &mut path_or_fd, global_this, - true, + false, )); } PathOrFileDescriptorSerializeTag::Path => { @@ -4147,6 +4166,12 @@ fn on_structured_clone_deserialize>( if strings::index_of_char(&path, 0).is_some() { return Err(crate::Error::InvalidValue); } + // No serializer writes one (`structured_clone_store_tag` refuses + // S3 stores), so this is a crafted record; honoring it would hand + // out an S3File signed with this process's environment credentials. + if path.starts_with(b"s3://") { + return Err(crate::Error::InvalidValue); + } // The owned `CowSlice` // adopts the `Box<[u8]>` so the store frees it in // `PathLike::drop`; borrowing here would drop `path` at scope @@ -4157,7 +4182,7 @@ fn on_structured_clone_deserialize>( break 'file Blob::new(Blob::find_or_create_file_from_path( &mut dest, global_this, - true, + false, )); } } diff --git a/src/runtime/webcore/blob/Store.rs b/src/runtime/webcore/blob/Store.rs index 35c6943d07d0..5c28532567d1 100644 --- a/src/runtime/webcore/blob/Store.rs +++ b/src/runtime/webcore/blob/Store.rs @@ -58,7 +58,6 @@ pub trait StoreExt { fn init_mmap(slice: &'static mut [u8]) -> StoreRef where Self: Sized; - fn serialize(&self, writer: &mut impl bun_io::Write) -> Result<(), crate::Error>; } pub trait S3Ext { @@ -88,6 +87,9 @@ pub trait S3Ext { pub trait FileExt { fn unlink(&self, global_this: &JSGlobalObject) -> JsResult; + /// Body of a `SerializeTag::File` structured-clone record, read back by + /// `Blob::on_structured_clone_deserialize`. + fn serialize(&self, writer: &mut impl bun_io::Write) -> Result<(), crate::Error>; } pub trait BytesExt { @@ -173,52 +175,6 @@ impl StoreExt for Store { is_all_ascii: None, })) } - - fn serialize(&self, writer: &mut impl bun_io::Write) -> Result<(), crate::Error> { - match &self.data { - Data::File(file) => { - let pathlike_tag: PathOrFileDescriptorSerializeTag = - if matches!(file.pathlike, PathOrFileDescriptor::Fd(_)) { - PathOrFileDescriptorSerializeTag::Fd - } else { - PathOrFileDescriptorSerializeTag::Path - }; - writer.write_int_le::(pathlike_tag as u8)?; - - match &file.pathlike { - PathOrFileDescriptor::Fd(fd) => { - // Write the raw bytes of the FD wrapper. `bun_sys::Fd` is - // `#[repr(transparent)]` over an integer (`i32` posix / - // `u64` windows), so its native-endian byte image is - // exactly the inner field's `to_ne_bytes()`. - writer.write_all(&fd.0.to_ne_bytes())?; - } - PathOrFileDescriptor::Path(path) => { - let path_slice = path.slice(); - writer.write_int_le::(path_slice.len() as u32)?; - writer.write_all(path_slice)?; - } - } - } - Data::S3(s3) => { - let pathlike_tag = PathOrFileDescriptorSerializeTag::Path; - writer.write_int_le::(pathlike_tag as u8)?; - - let path_slice = s3.pathlike.slice(); - writer.write_int_le::(path_slice.len() as u32)?; - writer.write_all(path_slice)?; - } - Data::Bytes(bytes) => { - let slice = bytes.slice(); - writer.write_int_le::(slice.len() as u32)?; - writer.write_all(slice)?; - - writer.write_int_le::(bytes.stored_name.len() as u32)?; - writer.write_all(&bytes.stored_name)?; - } - } - Ok(()) - } } impl FileExt for File { @@ -255,6 +211,32 @@ impl FileExt for File { )), } } + + fn serialize(&self, writer: &mut impl bun_io::Write) -> Result<(), crate::Error> { + let pathlike_tag: PathOrFileDescriptorSerializeTag = + if matches!(self.pathlike, PathOrFileDescriptor::Fd(_)) { + PathOrFileDescriptorSerializeTag::Fd + } else { + PathOrFileDescriptorSerializeTag::Path + }; + writer.write_int_le::(pathlike_tag as u8)?; + + match &self.pathlike { + PathOrFileDescriptor::Fd(fd) => { + // Write the raw bytes of the FD wrapper. `bun_sys::Fd` is + // `#[repr(transparent)]` over an integer (`i32` posix / + // `u64` windows), so its native-endian byte image is + // exactly the inner field's `to_ne_bytes()`. + writer.write_all(&fd.0.to_ne_bytes())?; + } + PathOrFileDescriptor::Path(path) => { + let path_slice = path.slice(); + writer.write_int_le::(path_slice.len() as u32)?; + writer.write_all(path_slice)?; + } + } + Ok(()) + } } impl S3Ext for S3 { diff --git a/test/js/web/structured-clone-blob-file.test.ts b/test/js/web/structured-clone-blob-file.test.ts index fe50739b5ed6..954705782991 100644 --- a/test/js/web/structured-clone-blob-file.test.ts +++ b/test/js/web/structured-clone-blob-file.test.ts @@ -1,6 +1,8 @@ +import { structuredCloneAdvanced } from "bun:internal-for-testing"; import { deserialize, serialize } from "bun:jsc"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isWindows, rss } from "harness"; +import { bunEnv, bunExe, isASAN, isWindows, rss, tempDir } from "harness"; +import { join } from "node:path"; import v8 from "node:v8"; describe("structuredClone with Blob and File", () => { @@ -361,6 +363,180 @@ describe("structuredClone with Blob and File", () => { }); }); + describe("S3File structured clone", () => { + // An S3File is bound to the credentials and options of the client that + // made it, and the Blob wire record has no form for those, so serializing + // one is refused up front with a DataCloneError. Before that, the record + // went out under the wrong store tag: every reader failed with "Unable to + // deserialize data." and postMessage() lost the message instead of + // throwing. Nothing here performs S3 I/O; the endpoint is never contacted. + const credentials = { + accessKeyId: "test-access-key", + secretAccessKey: "test-secret-key", + bucket: "bucket", + endpoint: "http://127.0.0.1:1", + }; + + const dataCloneError = { + type: "DOMException", + name: "DataCloneError", + code: DOMException.DATA_CLONE_ERR, + message: + "S3File cannot be cloned: it is bound to the credentials and options of the S3Client that created it. Pass the key instead and open it again with s3.file() where it is needed.", + }; + + function thrownBy(fn: () => unknown) { + try { + fn(); + } catch (e: any) { + return { type: e.constructor.name, name: e.name, code: e.code, message: e.message }; + } + return "did not throw"; + } + + test.each([ + ["Bun.s3.file(key, options)", () => Bun.s3.file("key.txt", credentials)], + ["Bun.s3.file(s3:// url, options)", () => Bun.s3.file("s3://bucket/key.txt", credentials)], + ["new Bun.S3Client(options).file(key)", () => new Bun.S3Client(credentials).file("key.txt")], + ["Bun.S3Client.file(key, options)", () => Bun.S3Client.file("key.txt", credentials)], + ["Bun.file(s3:// url)", () => Bun.file("s3://bucket/key.txt")], + ["S3File.slice()", () => new Bun.S3Client(credentials).file("key.txt").slice(1, 3)], + ])("structuredClone() of %s throws a DataCloneError", (_name, make) => { + const file = make(); + expect(Object.prototype.toString.call(file)).toBe("[object S3File]"); + const nameBefore = file.name; + + expect(thrownBy(() => structuredClone(file))).toEqual(dataCloneError); + + // The refused clone leaves the source file usable. + expect({ tag: Object.prototype.toString.call(file), name: file.name }).toEqual({ + tag: "[object S3File]", + name: nameBefore, + }); + }); + + test.each([ + ["structuredClone()", (value: unknown) => structuredClone(value)], + ["bun:jsc serialize()", (value: unknown) => serialize(value)], + ["node:v8 serialize()", (value: unknown) => v8.serialize(value)], + [ + "MessagePort.postMessage()", + (value: unknown) => { + const { port1, port2 } = new MessageChannel(); + try { + port1.postMessage(value); + } finally { + port1.close(); + port2.close(); + } + }, + ], + [ + "Worker.postMessage()", + (value: unknown) => { + const url = URL.createObjectURL(new Blob([""], { type: "application/javascript" })); + const worker = new Worker(url); + try { + worker.postMessage(value); + } finally { + worker.terminate(); + URL.revokeObjectURL(url); + } + }, + ], + ])("%s throws the DataCloneError at the sender", (_name, entryPoint) => { + expect(thrownBy(() => entryPoint(Bun.s3.file("key.txt", credentials)))).toEqual(dataCloneError); + expect(thrownBy(() => entryPoint({ files: [new Blob(["ok"]), Bun.s3.file("key.txt", credentials)] }))).toEqual( + dataCloneError, + ); + }); + + test("serializing for storage is refused too; cross-process transfer degrades to {} like every Blob", () => { + const s3 = Bun.s3.file("key.txt", credentials); + expect(thrownBy(() => structuredCloneAdvanced(s3, [], false, true, "default"))).toEqual(dataCloneError); + + // Blob is not transferable across processes, so the IPC-style + // serialization never reaches the Blob serializer: an S3File arrives as + // an empty object exactly like Bun.file() does, instead of throwing. + expect(structuredCloneAdvanced(s3, [], true, false, "default")).toStrictEqual({}); + expect(structuredCloneAdvanced(Bun.file("unused.txt"), [], true, false, "default")).toStrictEqual({}); + }); + + test("an S3File anywhere in the graph fails the whole clone; the rest still clones without it", async () => { + using dir = tempDir("s3-clone-graph", { "local.txt": "local bytes" }); + const local = Bun.file(join(String(dir), "local.txt")); + const blob = new Blob(["blob bytes"], { type: "text/plain" }); + const graph = { blob, local, deeper: { list: [1, "two", Bun.s3.file("key.txt", credentials)] } }; + + expect(thrownBy(() => structuredClone(graph))).toEqual(dataCloneError); + expect(thrownBy(() => serialize(graph))).toEqual(dataCloneError); + + const cloned = structuredClone({ blob, local }); + expect({ + blob: await cloned.blob.text(), + local: await cloned.local.text(), + source: { blob: await blob.text(), local: await local.text() }, + }).toEqual({ + blob: "blob bytes", + local: "local bytes", + source: { blob: "blob bytes", local: "local bytes" }, + }); + }); + + test("every other kind of Blob store still clones", async () => { + using dir = tempDir("blob-store-kinds", { "data.txt": "0123456789" }); + const path = join(String(dir), "data.txt"); + const cloned = structuredClone({ + empty: new Blob([]), + bytes: new Blob(["bytes"], { type: "text/plain" }), + file: new File(["file"], "named.txt"), + bunFile: Bun.file(path), + bunFileSlice: Bun.file(path).slice(2, 5), + }); + expect({ + empty: await cloned.empty.text(), + bytes: await cloned.bytes.text(), + file: [cloned.file.name, await cloned.file.text()], + bunFile: await cloned.bunFile.text(), + bunFileSlice: await cloned.bunFileSlice.text(), + }).toEqual({ + empty: "", + bytes: "bytes", + file: ["named.txt", "file"], + bunFile: "0123456789", + bunFileSlice: "234", + }); + }); + + test("a File record naming an s3:// path is rejected at deserialize", () => { + // Bun never writes one (S3 stores are refused above); honoring a + // hand-built one would hand out an S3File signed with this process's + // environment credentials. Build it by overwriting the path of a real + // file-backed record with an s3:// URL of the same length, so the + // framing stays whatever the current build emits. + const s3Path = "s3://bucket/key"; + const placeholder = Buffer.alloc(s3Path.length, "p").toString(); + const good = Buffer.from(serialize(Bun.file(placeholder))); + const at = good.indexOf(placeholder); + expect(at).toBeGreaterThan(0); + expect(Object.prototype.toString.call(deserialize(good))).toBe("[object Blob]"); + + const bad = Buffer.from(good); + bad.write(s3Path, at, "latin1"); + + const rejected = { + type: "TypeError", + name: "TypeError", + code: undefined, + message: "Unable to deserialize data.", + }; + expect({ + "bun:jsc": thrownBy(() => deserialize(bad)), + "node:v8": thrownBy(() => v8.deserialize(bad)), + }).toEqual({ "bun:jsc": rejected, "node:v8": rejected }); + }); + }); + describe("deserialize of crafted payloads", () => { // The Blob structured-clone wire format carries an `offset` (u64 LE) that // the sender controls. A malicious payload can set it past the end of the From c86045c4438f0d5f7d2137add42d894d76295897 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:36:20 +0000 Subject: [PATCH 2/5] S3File clone: test the 5-byte slice, Blob wrappers and source size The tests now clone a 5-byte S3 slice (it cloned into 5 wrong bytes with no error), new Blob([s3file]), new File([s3file], name) and formData.get() of an appended S3File. They also assert that a refused clone leaves the size of the source unchanged. The docs say that structured cloning is not supported, and no longer call it the only exception. The comments this change adds in Blob.rs and Store.rs are one line each. --- docs/runtime/s3.mdx | 2 +- src/runtime/webcore/Blob.rs | 16 ++--- src/runtime/webcore/blob/Store.rs | 8 +-- .../js/web/structured-clone-blob-file.test.ts | 61 +++++++++++++------ 4 files changed, 50 insertions(+), 37 deletions(-) diff --git a/docs/runtime/s3.mdx b/docs/runtime/s3.mdx index bd93bc1784b5..7cb5ae231e19 100644 --- a/docs/runtime/s3.mdx +++ b/docs/runtime/s3.mdx @@ -603,7 +603,7 @@ Like `Bun.file()`, `S3File` extends [`Blob`](https://developer.mozilla.org/en-US Because `S3File` extends `Blob`, `S3File` instances work with `fetch()`, `Response`, and other web APIs that accept `Blob` instances. -The exception is structured cloning. An `S3File` is bound to the credentials and options of the client that created it, so `structuredClone()` and `postMessage()` throw a `DataCloneError` instead of cloning it. To use a file from a worker, send the key and call `file()` on a client in the worker. +Structured cloning is not supported. An `S3File` is bound to the credentials and options of the client that created it, so `structuredClone()` and `postMessage()` throw a `DataCloneError`. To use a file from a worker, send the key and call `file()` on a client in the worker. ### Partial reads with `slice` diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 04433ac70c27..b58899937ef5 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -157,8 +157,6 @@ pub(crate) trait BlobExt { cx: &bun_jsc::JsThread<'_>, ); fn get_content_type(&self) -> Option>; - /// `store_tag` is `structured_clone_store_tag(self)`, which is `None` - /// (nothing to write) for the stores the record cannot describe. fn _on_structured_clone_serialize( &self, store_tag: store::SerializeTag, @@ -742,8 +740,7 @@ impl BlobExt for Blob { // on the wire and the receiver stats it locally, like v3. writer.write_int_le::(self.size.get())?; self.resolve_size(); - // Borrowed only after resolve_size(), which stats the file and - // writes the result into this same store. + // resolve_size() writes into this store: borrow the File only after it. store.data.as_file().serialize(writer)?; } } @@ -772,8 +769,7 @@ impl BlobExt for Blob { write_bytes: crate::generated_classes::WriteBytesFn, ) { let Some(store_tag) = structured_clone_store_tag(self) else { - // CloneSerializer checks for a pending exception after this hook - // returns and fails the whole clone with it. + // CloneSerializer runs RETURN_IF_EXCEPTION right after this hook returns. let _ = global_this.throw_dom_exception( bun_jsc::DOMExceptionCode::DataCloneError, format_args!( @@ -3768,9 +3764,7 @@ impl bun_io::Write for StructuredCloneWriter { } } -/// `None` for an S3 store: the record has no form for the credentials and -/// options of the S3Client it is bound to, and a clone that reopened the key -/// under whatever the receiver's environment holds would not be a clone. +/// `None` for an S3 store, whatever wraps it: the record cannot carry the client's credentials. fn structured_clone_store_tag(blob: &Blob) -> Option { let Some(store) = blob.store() else { return Some(store::SerializeTag::Empty); @@ -3901,9 +3895,7 @@ fn on_structured_clone_deserialize>( if strings::index_of_char(&path, 0).is_some() { return Err(crate::Error::InvalidValue); } - // No serializer writes one (`structured_clone_store_tag` refuses - // S3 stores), so this is a crafted record; honoring it would hand - // out an S3File signed with this process's environment credentials. + // Crafted record: no serializer writes an `s3://` path. if path.starts_with(b"s3://") { return Err(crate::Error::InvalidValue); } diff --git a/src/runtime/webcore/blob/Store.rs b/src/runtime/webcore/blob/Store.rs index 36fa33ccf07f..69de544d3b23 100644 --- a/src/runtime/webcore/blob/Store.rs +++ b/src/runtime/webcore/blob/Store.rs @@ -85,8 +85,7 @@ pub(crate) trait S3Ext { pub(crate) trait FileExt { fn unlink(&self, cx: &bun_jsc::JsThread<'_>) -> JsResult; - /// Body of a `SerializeTag::File` structured-clone record, read back by - /// `Blob::on_structured_clone_deserialize`. + /// Body of a `SerializeTag::File` structured-clone record. fn serialize(&self, writer: &mut impl bun_io::Write) -> Result<(), crate::Error>; } @@ -212,10 +211,7 @@ impl FileExt for File { match &self.pathlike { PathOrFileDescriptor::Fd(fd) => { - // Write the raw bytes of the FD wrapper. `bun_sys::Fd` is - // `#[repr(transparent)]` over an integer (`i32` posix / - // `u64` windows), so its native-endian byte image is - // exactly the inner field's `to_ne_bytes()`. + // `Fd` is `#[repr(transparent)]`; read back with `read_struct::()`. writer.write_all(&fd.0.to_ne_bytes())?; } PathOrFileDescriptor::Path(path) => { diff --git a/test/js/web/structured-clone-blob-file.test.ts b/test/js/web/structured-clone-blob-file.test.ts index ce841c03b9df..e9b89820117a 100644 --- a/test/js/web/structured-clone-blob-file.test.ts +++ b/test/js/web/structured-clone-blob-file.test.ts @@ -364,12 +364,14 @@ describe("structuredClone with Blob and File", () => { }); describe("S3File structured clone", () => { - // An S3File is bound to the credentials and options of the client that + // An S3 store is bound to the credentials and options of the client that // made it, and the Blob wire record has no form for those, so serializing // one is refused up front with a DataCloneError. Before that, the record - // went out under the wrong store tag: every reader failed with "Unable to - // deserialize data." and postMessage() lost the message instead of - // throwing. Nothing here performs S3 I/O; the endpoint is never contacted. + // went out under the Bytes tag with the body of a File record. Most + // readers failed with "Unable to deserialize data.", postMessage() lost + // the message instead of throwing, and a 5-byte slice parsed as a Blob of + // 5 wrong bytes. Each attempt also set the size of the source to 0. + // Nothing here performs S3 I/O; the endpoint is never contacted. const credentials = { accessKeyId: "test-access-key", secretAccessKey: "test-secret-key", @@ -395,26 +397,49 @@ describe("structuredClone with Blob and File", () => { } test.each([ - ["Bun.s3.file(key, options)", () => Bun.s3.file("key.txt", credentials)], - ["Bun.s3.file(s3:// url, options)", () => Bun.s3.file("s3://bucket/key.txt", credentials)], - ["new Bun.S3Client(options).file(key)", () => new Bun.S3Client(credentials).file("key.txt")], - ["Bun.S3Client.file(key, options)", () => Bun.S3Client.file("key.txt", credentials)], - ["Bun.file(s3:// url)", () => Bun.file("s3://bucket/key.txt")], - ["S3File.slice()", () => new Bun.S3Client(credentials).file("key.txt").slice(1, 3)], - ])("structuredClone() of %s throws a DataCloneError", (_name, make) => { + ["Bun.s3.file(key, options)", () => Bun.s3.file("key.txt", credentials), NaN], + ["Bun.s3.file(s3:// url, options)", () => Bun.s3.file("s3://bucket/key.txt", credentials), NaN], + ["new Bun.S3Client(options).file(key)", () => new Bun.S3Client(credentials).file("key.txt"), NaN], + ["Bun.S3Client.file(key, options)", () => Bun.S3Client.file("key.txt", credentials), NaN], + ["Bun.file(s3:// url)", () => Bun.file("s3://bucket/key.txt"), NaN], + ["S3File.slice()", () => new Bun.S3Client(credentials).file("key.txt").slice(1, 3), 2], + // Without the refusal, this window cloned with no error into a Blob of the bytes 00 00 00 00 01. + ["S3File.slice() of 5 bytes", () => new Bun.S3Client(credentials).file("key.txt").slice(0, 5), 5], + ])("structuredClone() of %s throws a DataCloneError", (_name, make, size) => { const file = make(); - expect(Object.prototype.toString.call(file)).toBe("[object S3File]"); const nameBefore = file.name; + expect({ tag: Object.prototype.toString.call(file), size: file.size }).toEqual({ tag: "[object S3File]", size }); expect(thrownBy(() => structuredClone(file))).toEqual(dataCloneError); - // The refused clone leaves the source file usable. - expect({ tag: Object.prototype.toString.call(file), name: file.name }).toEqual({ + // The refused clone leaves the source file as it was. + expect({ tag: Object.prototype.toString.call(file), name: file.name, size: file.size }).toEqual({ tag: "[object S3File]", name: nameBefore, + size, }); }); + test.each([ + ["new Blob([s3file])", (s3file: Blob) => new Blob([s3file])], + ["new File([s3file], name)", (s3file: Blob) => new File([s3file], "n.txt")], + [ + "formData.get() of an appended S3File", + (s3file: Blob) => { + const form = new FormData(); + form.append("f", s3file); + return form.get("f") as Blob; + }, + ], + ])("%s is not an S3File, shares its store, and is refused the same way", (_name, wrap) => { + const blob = wrap(Bun.s3.file("key.txt", credentials)); + expect(Object.prototype.toString.call(blob)).not.toBe("[object S3File]"); + expect(blob.size).toBeNaN(); + + expect(thrownBy(() => structuredClone(blob))).toEqual(dataCloneError); + expect(blob.size).toBeNaN(); + }); + test.each([ ["structuredClone()", (value: unknown) => structuredClone(value)], ["bun:jsc serialize()", (value: unknown) => serialize(value)], @@ -445,10 +470,10 @@ describe("structuredClone with Blob and File", () => { }, ], ])("%s throws the DataCloneError at the sender", (_name, entryPoint) => { - expect(thrownBy(() => entryPoint(Bun.s3.file("key.txt", credentials)))).toEqual(dataCloneError); - expect(thrownBy(() => entryPoint({ files: [new Blob(["ok"]), Bun.s3.file("key.txt", credentials)] }))).toEqual( - dataCloneError, - ); + const file = Bun.s3.file("key.txt", credentials); + expect(thrownBy(() => entryPoint(file))).toEqual(dataCloneError); + expect(thrownBy(() => entryPoint({ files: [new Blob(["ok"]), file] }))).toEqual(dataCloneError); + expect(file.size).toBeNaN(); }); test("serializing for storage is refused too; cross-process transfer degrades to {} like every Blob", () => { From ef2bca7829a034180166cef1eccf22693b20f46a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:51:43 +0000 Subject: [PATCH 3/5] S3File clone tests: one-line comments The block comment states the invariant in one line. The two slice rows are named by their windows. --- test/js/web/structured-clone-blob-file.test.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/test/js/web/structured-clone-blob-file.test.ts b/test/js/web/structured-clone-blob-file.test.ts index e9b89820117a..517373a96c7c 100644 --- a/test/js/web/structured-clone-blob-file.test.ts +++ b/test/js/web/structured-clone-blob-file.test.ts @@ -364,19 +364,12 @@ describe("structuredClone with Blob and File", () => { }); describe("S3File structured clone", () => { - // An S3 store is bound to the credentials and options of the client that - // made it, and the Blob wire record has no form for those, so serializing - // one is refused up front with a DataCloneError. Before that, the record - // went out under the Bytes tag with the body of a File record. Most - // readers failed with "Unable to deserialize data.", postMessage() lost - // the message instead of throwing, and a 5-byte slice parsed as a Blob of - // 5 wrong bytes. Each attempt also set the size of the source to 0. - // Nothing here performs S3 I/O; the endpoint is never contacted. + // An S3 store carries credentials that the clone record has no field for, so the clone is refused. const credentials = { accessKeyId: "test-access-key", secretAccessKey: "test-secret-key", bucket: "bucket", - endpoint: "http://127.0.0.1:1", + endpoint: "http://127.0.0.1:1", // never contacted: no test here does S3 I/O }; const dataCloneError = { @@ -402,9 +395,8 @@ describe("structuredClone with Blob and File", () => { ["new Bun.S3Client(options).file(key)", () => new Bun.S3Client(credentials).file("key.txt"), NaN], ["Bun.S3Client.file(key, options)", () => Bun.S3Client.file("key.txt", credentials), NaN], ["Bun.file(s3:// url)", () => Bun.file("s3://bucket/key.txt"), NaN], - ["S3File.slice()", () => new Bun.S3Client(credentials).file("key.txt").slice(1, 3), 2], - // Without the refusal, this window cloned with no error into a Blob of the bytes 00 00 00 00 01. - ["S3File.slice() of 5 bytes", () => new Bun.S3Client(credentials).file("key.txt").slice(0, 5), 5], + ["S3File.slice(1, 3)", () => new Bun.S3Client(credentials).file("key.txt").slice(1, 3), 2], + ["S3File.slice(0, 5)", () => new Bun.S3Client(credentials).file("key.txt").slice(0, 5), 5], ])("structuredClone() of %s throws a DataCloneError", (_name, make, size) => { const file = make(); const nameBefore = file.name; From 5ae84cb7bbc226ef0425341c1a6431a12b1c8db5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:56:54 +0000 Subject: [PATCH 4/5] S3File clone: an s3:// File record deserializes to a local file store The deserializer passes check_s3 = false, so a File record that names an s3:// path builds a local File store, which is what a record written before S3 support meant. No hard error for that record. --- src/runtime/webcore/Blob.rs | 4 --- .../js/web/structured-clone-blob-file.test.ts | 29 ++++++++----------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index b58899937ef5..576eb31b7739 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3895,10 +3895,6 @@ fn on_structured_clone_deserialize>( if strings::index_of_char(&path, 0).is_some() { return Err(crate::Error::InvalidValue); } - // Crafted record: no serializer writes an `s3://` path. - if path.starts_with(b"s3://") { - return Err(crate::Error::InvalidValue); - } let mut dest = PathOrFileDescriptor::Path(PathLike::owned(path)); break 'file Blob::new(Blob::find_or_create_file_from_path( &mut dest, diff --git a/test/js/web/structured-clone-blob-file.test.ts b/test/js/web/structured-clone-blob-file.test.ts index 517373a96c7c..655404ea291f 100644 --- a/test/js/web/structured-clone-blob-file.test.ts +++ b/test/js/web/structured-clone-blob-file.test.ts @@ -525,12 +525,12 @@ describe("structuredClone with Blob and File", () => { }); }); - test("a File record naming an s3:// path is rejected at deserialize", () => { - // Bun never writes one (S3 stores are refused above); honoring a - // hand-built one would hand out an S3File signed with this process's - // environment credentials. Build it by overwriting the path of a real - // file-backed record with an s3:// URL of the same length, so the - // framing stays whatever the current build emits. + test("a File record naming an s3:// path deserializes to a local file, never an S3File", () => { + // A File record is a local path. A record that names `s3://...` must not + // hand out an S3File signed with this process's environment credentials. + // Build it by overwriting the path of a real file-backed record with an + // s3:// URL of the same length, so the framing stays whatever the + // current build emits. const s3Path = "s3://bucket/key"; const placeholder = Buffer.alloc(s3Path.length, "p").toString(); const good = Buffer.from(serialize(Bun.file(placeholder))); @@ -538,19 +538,14 @@ describe("structuredClone with Blob and File", () => { expect(at).toBeGreaterThan(0); expect(Object.prototype.toString.call(deserialize(good))).toBe("[object Blob]"); - const bad = Buffer.from(good); - bad.write(s3Path, at, "latin1"); + const crafted = Buffer.from(good); + crafted.write(s3Path, at, "latin1"); - const rejected = { - type: "TypeError", - name: "TypeError", - code: undefined, - message: "Unable to deserialize data.", - }; + const tagOf = (blob: unknown) => Object.prototype.toString.call(blob); expect({ - "bun:jsc": thrownBy(() => deserialize(bad)), - "node:v8": thrownBy(() => v8.deserialize(bad)), - }).toEqual({ "bun:jsc": rejected, "node:v8": rejected }); + "bun:jsc": tagOf(deserialize(crafted)), + "node:v8": tagOf(v8.deserialize(crafted)), + }).toEqual({ "bun:jsc": "[object Blob]", "node:v8": "[object Blob]" }); }); }); From b3a90c3556f0f337690074743c06fbff297fe400 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:04:59 +0000 Subject: [PATCH 5/5] ci: retrigger