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
10 changes: 8 additions & 2 deletions src/jsc/bindings/DOMFormData.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,15 @@ void DOMFormData::append(const String& name, const String& value)
m_items.append(createStringEntry(name, value));
}

// Null means "not a FormData entry" to toJS(Blob&), so an entry's filename is never null.
static String sanitizedFileName(const String& filename)
{
return filename.isNull() ? emptyString() : replaceUnpairedSurrogatesWithReplacementCharacter(String(filename));
}

void DOMFormData::append(const String& name, RefPtr<Blob> blob, const String& filename)
{
blob->setFileName(replaceUnpairedSurrogatesWithReplacementCharacter(String(filename)));
blob->setFileName(sanitizedFileName(filename));
m_items.append({ replaceUnpairedSurrogatesWithReplacementCharacter(String(name)), blob });
}
void DOMFormData::remove(const StringView name)
Expand Down Expand Up @@ -148,7 +154,7 @@ void DOMFormData::set(const String& name, const String& value)

void DOMFormData::set(const String& name, RefPtr<Blob> blob, const String& filename)
{
blob->setFileName(filename);
blob->setFileName(sanitizedFileName(filename));
set(name, { name, blob });
}

Expand Down
16 changes: 12 additions & 4 deletions src/jsc/bindings/blob.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,26 @@ extern "C" void Blob__setAsFile(void* impl, const BunString* filename);

namespace WebCore {

// A null fileName means the Blob is not a FormData entry: it has no name to give.
static BunString fileNameOrDead(const String& fileName)
{
return fileName.isNull() ? BunString { BunStringTag::Dead } : Bun::toString(fileName);
}

JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, WebCore::Blob& impl)
{
BunString filename = Bun::toString(impl.fileName());
Blob__setAsFile(impl.impl(), &filename);
const String& entryName = impl.fileName();
BunString filename = fileNameOrDead(entryName);
void* dupe = Blob__dupe(impl.impl());
Blob__setAsFile(dupe, &filename);

return JSC::JSValue::decode(Blob__create(lexicalGlobalObject, Blob__dupe(impl.impl())));
return JSC::JSValue::decode(Blob__create(lexicalGlobalObject, dupe));
}

JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, Ref<WebCore::Blob>&& impl)
Comment thread
claude[bot] marked this conversation as resolved.
{
auto fileNameStr = impl->fileName();
BunString filename = Bun::toString(fileNameStr);
BunString filename = fileNameOrDead(fileNameStr);

JSC::EncodedJSValue encoded = Blob__create(lexicalGlobalObject, impl->impl());
JSBlob* blob = uncheckedDowncast<JSBlob>(JSC::JSValue::decode(encoded));
Expand Down
18 changes: 16 additions & 2 deletions src/jsc/bindings/webcore/JSDOMFormData.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,20 @@ static inline JSC::EncodedJSValue jsDOMFormDataPrototypeFunction_append1Body(JSC

extern "C" BunString Blob__getFileNameString(void* impl);

// https://xhr.spec.whatwg.org/#create-an-entry
static String entryFileName(JSC::JSGlobalObject* lexicalGlobalObject, Blob& blob, JSC::JSValue filenameArg)
{
if (!filenameArg.isUndefined())
return convert<IDLUSVString>(*lexicalGlobalObject, filenameArg);

BunString blobName = Blob__getFileNameString(blob.impl());
if (blobName.tag == BunStringTag::Dead) {
static NeverDestroyed<String> defaultName = MAKE_STATIC_STRING_IMPL("blob");
return defaultName.get();
Comment thread
claude[bot] marked this conversation as resolved.
}
return blobName.transferToWTFString();
Comment thread
robobun marked this conversation as resolved.
}

static inline JSC::EncodedJSValue jsDOMFormDataPrototypeFunction_append2Body(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation<JSDOMFormData>::ClassParameter castedThis)
{
auto& vm = JSC::getVM(lexicalGlobalObject);
Expand All @@ -308,7 +322,7 @@ static inline JSC::EncodedJSValue jsDOMFormDataPrototypeFunction_append2Body(JSC
RETURN_IF_EXCEPTION(throwScope, {});

EnsureStillAliveScope argument2 = callFrame->argument(2);
auto filename = argument2.value().isUndefined() ? Blob__getFileNameString(blobValue->impl()).transferToWTFString() : convert<IDLUSVString>(*lexicalGlobalObject, argument2.value());
auto filename = entryFileName(lexicalGlobalObject, *blobValue, argument2.value());
RETURN_IF_EXCEPTION(throwScope, {});

RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS<IDLUndefined>(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.append(WTF::move(name), WTF::move(blobValue), WTF::move(filename)); })));
Expand Down Expand Up @@ -471,7 +485,7 @@ static inline JSC::EncodedJSValue jsDOMFormDataPrototypeFunction_set2Body(JSC::J
}
RETURN_IF_EXCEPTION(throwScope, {});

auto filename = argument2.value().isUndefined() ? Blob__getFileNameString(blobValue->impl()).transferToWTFString() : convert<IDLUSVString>(*lexicalGlobalObject, argument2.value());
auto filename = entryFileName(lexicalGlobalObject, *blobValue, argument2.value());
RETURN_IF_EXCEPTION(throwScope, {});

RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS<IDLUndefined>(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.set(WTF::move(name), WTF::move(blobValue), WTF::move(filename)); })));
Expand Down
26 changes: 12 additions & 14 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1956,19 +1956,17 @@ impl BlobExt for Blob {

fn get_loader(&self, jsc_vm: &VirtualMachine) -> Option<bun_ast::Loader> {
use bun_resolver::fs::PathResolverExt as _;
if let Some(filename) = self.get_file_name() {
let current_path = bun_resolver::fs::Path::init(&filename);
return Some(
current_path
.loader(&jsc_vm.transpiler.options.loaders)
.unwrap_or(bun_ast::Loader::Tsx),
);
} else if let Some(mime_type) = self.get_mime_type_or_content_type() {
let from_extension = self.get_file_name().and_then(|filename| {
bun_resolver::fs::Path::init(&filename).loader(&jsc_vm.transpiler.options.loaders)
});
if let Some(loader) = from_extension {
return Some(loader);
}
if let Some(mime_type) = self.get_mime_type_or_content_type() {
return Some(bun_ast::Loader::from_mime_type(mime_type));
} else {
// Be maximally permissive.
return Some(bun_ast::Loader::Tsx);
}
// Be maximally permissive.
Some(bun_ast::Loader::Tsx)
}

// TODO: Move this to a separate `File` object or BunFile
Expand Down Expand Up @@ -3993,10 +3991,11 @@ pub(crate) extern "C" fn Blob__dupeFromJS(value: JSValue) -> Option<NonNull<Blob
)
}

/// `path_str` is the FormData entry's filename, or Dead for a Blob that is not an entry.
#[unsafe(no_mangle)]
pub(crate) extern "C" fn Blob__setAsFile(this: &mut Blob, path_str: &BunString) {
this.is_jsdom_file.set(true);
if !path_str.is_empty() && this.get_file_name().is_none() {
if path_str.tag() != bun_core::Tag::Dead {
this.name.set(path_str.clone());
Comment thread
claude[bot] marked this conversation as resolved.
}
}
Expand All @@ -4008,8 +4007,7 @@ pub(crate) extern "C" fn Blob__dupe(this: &Blob) -> *mut Blob {

#[unsafe(no_mangle)]
pub(crate) extern "C" fn Blob__getFileNameString(this: &Blob) -> BunString {
this.get_name_string()
.map_or(BunString::EMPTY, Clone::clone)
this.get_name_string().map_or(BunString::DEAD, Clone::clone)
}

// ──────────────────────────────────────────────────────────────────────────
Expand Down
24 changes: 24 additions & 0 deletions test/js/web/fetch/blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,30 @@ test("blob: can be imported", async () => {
}).toThrow();
});

test("blob: the type picks the loader when the name has no known extension", async () => {
const json = '{"a":1}';
const type = "application/json";
const form = new FormData();
form.append("entry", new Blob([json], { type }));
const blobs = {
unnamed: new Blob([json], { type }),
named: new File([json], "data", { type }),
// FormData names this entry "blob".
entry: form.get("entry") as File,
extension: new File(["export default 2"], "mod.js", { type }),
};
const loaded: Record<string, unknown> = {};
for (const [key, blob] of Object.entries(blobs)) {
const url = URL.createObjectURL(blob);
loaded[key] = await import(url).then(
module => module.default,
error => error.name,
);
URL.revokeObjectURL(url);
}
expect(loaded).toEqual({ unnamed: { a: 1 }, named: { a: 1 }, entry: { a: 1 }, extension: 2 });
});

test("blob: can reliable get type from fetch #10072", async () => {
using server = Bun.serve({
fetch() {
Expand Down
4 changes: 2 additions & 2 deletions test/js/web/html/FormData-multipart-serialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("multipart serialization (new Response(formData))", () => {
`--${boundary}\r\n`,
`Content-Disposition: form-data; name="crlf%0D%0Aname"\r\n\r\nv2\r\n`,
`--${boundary}\r\n`,
`Content-Disposition: form-data; name="untyped-blob"; filename=""\r\n`,
`Content-Disposition: form-data; name="untyped-blob"; filename="blob"\r\n`,
`Content-Type: application/octet-stream\r\n\r\nblob-bytes\r\n`,
`--${boundary}\r\n`,
`Content-Disposition: form-data; name="named-blob"; filename="file.bin"\r\n`,
Expand Down Expand Up @@ -136,7 +136,7 @@ describe("multipart serialization (new Response(formData))", () => {
["dup", "first"],
["dup", "second"],
["unicode name ☺", "ünïcode välue 😊"],
["blob", { file: true, name: undefined as any, type: "", text: "blob-bytes" }],
["blob", { file: true, name: "blob", type: "", text: "blob-bytes" }],
["file", { file: true, name: "日本語ファイル名.html", type: "text/html;charset=utf-8", text: "<p>hi</p>" }],
]);
});
Expand Down
Loading
Loading