diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 742ccb030cbf..c3e5218b3d79 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -438,6 +438,15 @@ impl Blob { matches!(self.store.get().as_deref(), Some(s) if matches!(s.data, store::Data::File(_))) } + /// `Bytes.stored_name` is the DOM File name carried on the shared store; + /// it must not surface on a plain Blob that merely shares that store + /// (e.g. the result of `file.slice()`). + #[inline] + pub fn hides_bytes_stored_name(&self) -> bool { + !self.is_jsdom_file.get() + && matches!(self.store.get().as_deref(), Some(s) if matches!(s.data, store::Data::Bytes(_))) + } + /// `Blob.getFileName()` — the user-visible name: `Bytes.stored_name`, /// the file path, or the S3 key. `None` for fd-backed or unnamed blobs. pub fn get_file_name(&self) -> Option<&[u8]> { diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 04861e5dde0d..c329c7a2928b 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -2016,6 +2016,16 @@ impl BlobExt for Blob { blob.offset.set(offset); blob.size.set(len); + // Per the File API, slice() returns a new plain Blob regardless of the + // receiver's subclass, so drop the File brand and its identity fields. + // For File/S3-backed stores the DOM name lives only in `blob.name`; + // clearing it would unmask the on-disk path via get_file_name(). + blob.is_jsdom_file.set(false); + blob.last_modified.set(0.0); + if blob.hides_bytes_stored_name() { + blob.name.set(BunString::dead()); + } + let content_type_was_allocated = content_type.is_owned() && !content_type.is_empty(); // infer the content type if it was not specified if content_type.is_empty() @@ -2138,6 +2148,9 @@ impl BlobExt for Blob { if self.name.get().tag() != bun_core::Tag::Dead { return Some(self.name.get()); } + if self.hides_bytes_stored_name() { + return None; + } if let Some(path) = self.get_file_name() { self.name.set(BunString::clone_utf8(path)); return Some(self.name.get()); @@ -2445,6 +2458,16 @@ impl BlobExt for Blob { _ => { blob = Blob::get::(global_this, args[0])?; + // `new Blob(parts)` always yields a plain Blob even when `parts` + // contains a File, so drop any File identity propagated by dupe(). + // For File/S3-backed stores the DOM name lives only in `blob.name`; + // clearing it would unmask the on-disk path via get_file_name(). + blob.is_jsdom_file.set(false); + blob.last_modified.set(0.0); + if blob.hides_bytes_stored_name() { + blob.name.set(BunString::dead()); + } + if args.len() > 1 { let options = args[1]; if options.is_object() { @@ -4402,6 +4425,9 @@ pub extern "C" fn Blob__dupe(this: &Blob) -> *mut Blob { #[unsafe(no_mangle)] pub extern "C" fn Blob__getFileNameString(this: &Blob) -> BunString { + if this.hides_bytes_stored_name() { + return BunString::empty(); + } if let Some(filename) = this.get_file_name() { return BunString::from_bytes(filename); } diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index b40ff2870fe3..9897c5e75c3a 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -244,6 +244,85 @@ describe("new File() lastModified option", () => { }); }); +test("File.prototype.slice() returns a Blob, not a File", async () => { + const file = new File(["0123456789"], "secret-report.pdf", { type: "text/plain", lastModified: 1234 }); + const sliced = file.slice(2, 5); + + expect({ + isFile: sliced instanceof File, + isBlob: sliced instanceof Blob, + name: (sliced as any).name, + size: sliced.size, + text: await sliced.text(), + }).toEqual({ + isFile: false, + isBlob: true, + name: undefined, + size: 3, + text: "234", + }); + expect((sliced as any).lastModified).not.toBe(1234); + + // empty File: slice() takes the size==0 early return + const emptySlice = new File([], "empty.txt", { lastModified: 999 }).slice(); + expect(emptySlice instanceof File).toBe(false); + expect((emptySlice as any).name).toBeUndefined(); + + // new Blob([file]) is a plain Blob, not a File + const wrapped = new Blob([file]); + expect({ + isFile: wrapped instanceof File, + isBlob: wrapped instanceof Blob, + name: (wrapped as any).name, + }).toEqual({ + isFile: false, + isBlob: true, + name: undefined, + }); + + // FormData must not emit the parent File's name for a slice + const fd = new FormData(); + fd.append("part", file.slice(2, 5)); + fd.append("whole", file); + const multipart = await new Response(fd).text(); + const filenames = [...multipart.matchAll(/name="([^"]*)"; filename="([^"]*)"/g)].map(m => [m[1], m[2]]); + expect(filenames).toEqual([ + ["part", ""], + ["whole", "secret-report.pdf"], + ]); + + // original File is untouched + expect({ + isFile: file instanceof File, + name: file.name, + lastModified: file.lastModified, + }).toEqual({ + isFile: true, + name: "secret-report.pdf", + lastModified: 1234, + }); + + // structuredClone of a File still yields a File + const cloned = structuredClone(file); + expect({ + isFile: cloned instanceof File, + name: cloned.name, + lastModified: cloned.lastModified, + }).toEqual({ + isFile: true, + name: "secret-report.pdf", + lastModified: 1234, + }); + + // slicing a DOM File that wraps a Bun.file() must not unmask the disk path + using dir = tempDir("file-slice-bunfile", { "private-secrets.csv": "0123456789" }); + const diskPath = path.join(String(dir), "private-secrets.csv"); + const wrapped2 = new File([Bun.file(diskPath)], "public.csv"); + const sliced2 = wrapped2.slice(0, 5); + expect(sliced2 instanceof File).toBe(false); + expect((sliced2 as any).name).not.toBe(diskPath); +}); + test("new Blob('123') is NOT supported", async () => { expect(() => new Blob("123")).toThrow(); });