Skip to content
Open
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
9 changes: 9 additions & 0 deletions src/jsc/webcore_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]> {
Expand Down
26 changes: 26 additions & 0 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -2445,6 +2458,16 @@ impl BlobExt for Blob {
_ => {
blob = Blob::get::<false, true>(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() {
Expand Down Expand Up @@ -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();
}
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
if let Some(filename) = this.get_file_name() {
return BunString::from_bytes(filename);
}
Expand Down
79 changes: 79 additions & 0 deletions test/js/web/fetch/blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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();
});
Expand Down
Loading