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
2 changes: 1 addition & 1 deletion packages/bun-types/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,7 +1566,7 @@ interface Navigator {
declare var navigator: Navigator;

interface BlobPropertyBag {
/** Set a default "type". Not yet implemented. */
/** The MIME type of the `Blob`, for example `"text/html"`. */
type?: string;
/** Not implemented in Bun yet. */
// endings?: "transparent" | "native";
Expand Down
7 changes: 3 additions & 4 deletions src/codegen/builtins/host/web-classes.builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ const joinedParts = [
b.readsContents(),
b.runsTransitively("OwnValues", "PrototypeChain", "ToPrimitive", b.named("length")),
];
const blobOptions = membersInspected("type");
const blobOptions = [b.runsTransitively("ToPrimitive", b.named("type"))];

const blobConstruction = [
{
Expand Down Expand Up @@ -451,12 +451,11 @@ const membersOfBlob = {
},
},
size: measuredNumber,
// An operand of another kind than a number or a string counts as absent.
slice: {
...nativeOnly,
...operandCodeOnly,
args: { start: anythingOrAbsent, end: anythingOrAbsent, contentType: anythingOrAbsent },
returns: newBlobOverSameBytes,
uses: { this: stateRead, start: b.inspected, end: b.inspected, contentType: b.inspected },
uses: { this: stateRead, start: converted, end: converted, contentType: converted },
reads: externalState,
},
// The promise is resolved with a Stats object, which looks for `then` on a prototype that is not locked.
Expand Down
51 changes: 22 additions & 29 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1803,7 +1803,8 @@ impl BlobExt for Blob {
}

let mut blob_content_type = BlobContentType::default();
if content_type.is_string() {
// A DOMString: ToString converts every value but `undefined`.
if !content_type.is_undefined() {
let content_type_view = content_type.to_js_string_view(global_this)?;
let slicer = content_type_view.to_utf8();
let slice = slicer.slice();
Expand Down Expand Up @@ -2156,19 +2157,7 @@ impl BlobExt for Blob {
blob = Blob::get::<false, true>(global_this, parts)?;

if options.is_object() {
if let Some(content_type) = options.get(global_this, "type")? {
'inner: {
if content_type.is_string() {
let content_type_str = content_type.to_utf8(global_this)?;
let slice = content_type_str.slice();
if !is_valid_blob_type(slice) {
break 'inner;
}
blob.content_type_was_set.set(true);
blob.content_type.set(BlobContentType::from_script(slice));
}
}
}
set_type_from_options(global_this, &blob, options)?;
}

if blob.content_type_slice().is_empty() {
Expand Down Expand Up @@ -5268,6 +5257,24 @@ fn write_bytes_to_file_fast<const NEEDS_OPEN: bool>(
JSPromise::resolved_promise_value(global_this, JSValue::js_number(written as f64))
}

/// Gives `blob` the `type` of the options of `new Blob()` or `new File()`. The File API declares
/// it a DOMString: ToString converts every value that is present.
fn set_type_from_options(
global_this: &JSGlobalObject,
blob: &Blob,
options: JSValue,
) -> JsResult<()> {
if let Some(content_type) = options.fast_get(global_this, jsc::BuiltinName::Type)? {
let content_type_str = content_type.to_utf8(global_this)?;
Comment on lines +5267 to +5268

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Programs that pass a MIME lookup result as type now upload Content-Type: null or Content-Type: false where the base sent no header. set_type_from_options at Blob.rs:5268 calls to_utf8 on every present value, so null and false become the text "null" / "false" and pass is_valid_blob_type. This follows from the stated DOMString purpose and the author flags it as a maintainer decision, but it is a silent wire-level break for servers that reject unknown media types. Fix: a maintainer must accept it explicitly; if the spec behaviour stays, say in the type JSDoc at packages/bun-types/globals.d.ts:1569 and the release notes that null/false become "null"/"false" and are sent as the Content-Type. [also at: src/runtime/webcore/Blob.rs:1807 - Programs that slice with a null or false contentType now get a sub-Blob typed "null" or "false" and upload Content-Type: null, where the base inherited or dropped the type.]

Why this was flagged

A program does new File([buf], name, { type: mimeTypes.lookup(name) }) or new Blob([body], { type: res.headers.get("content-type") }) and the lookup misses, giving false or null. On the base, jsdom_blob_construct/jsdom_file_construct tested content_type.is_string() first and ignored the value, so .type was "" and a fetch upload carried no Content-Type. After the change, set_type_from_options at src/runtime/webcore/Blob.rs:5268 runs to_utf8 on the value, "null"/"false" is all printable ASCII so is_valid_blob_type at Blob.rs:74 accepts it, and Blob.rs:5271-5272 store it as the type. A receiving server that validates Content-Type now gets a 415-class failure that it did not get before. No guard in the diff prevents it; the PR description lists it under Downsides as a question for a maintainer, and the .d.ts hunk at packages/bun-types/globals.d.ts:1569 does not mention it.

Verification: set_type_from_options at src/runtime/webcore/Blob.rs:5262-5276 calls content_type.to_utf8(global_this)? (giving "null"/"false") and is_valid_blob_type at Blob.rs:74-76 only checks printable ASCII, so these pass and content_type_was_set is set. Base code (removed hunks at old lines 2159-2170 and 5318-5329) guarded with content_type.is_string(), leaving .type === "".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed: a maintainer must accept this. It is the first item under Downsides, and the Notes have the table of what changes on the wire.

The facts for that decision:

  • WebIDL takes only undefined as absent for a DOMString. Node.js v26.3.0 gives "null" and "false" at the three sites. web-platform-tests asserts "null" for slice (null type Blob slice in FileAPI/blob/Blob-slice.any.js).
  • The cost is the one that you name. A program that passed the result of a failed lookup sent no header. It now sends Content-Type: null.

The code and the JSDoc stay as they are until a maintainer answers, because the two answers need different text. If the conversion stays, I add a sentence on null and false to the JSDoc of BlobPropertyBag.type. If it does not, the constructors keep null and false absent.

This thread stays open for that answer.

let slice = content_type_str.slice();
if is_valid_blob_type(slice) {
blob.content_type_was_set.set(true);
blob.content_type.set(BlobContentType::from_script(slice));
}
}
Ok(())
}

// ──────────────────────────────────────────────────────────────────────────
// JSDOMFile constructor
// ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -5313,21 +5320,7 @@ pub(crate) fn jsdom_file_construct(
let mut set_last_modified = false;

if options.is_object() {
// type, the ASCII-encoded string in lower case
// representing the media type of the Blob.
if let Some(content_type) = options.get(global_this, "type")? {
'inner: {
if content_type.is_string() {
let content_type_str = content_type.to_utf8(global_this)?;
let slice = content_type_str.slice();
if !is_valid_blob_type(slice) {
break 'inner;
}
blob.content_type_was_set.set(true);
blob.content_type.set(BlobContentType::from_script(slice));
}
}
}
set_type_from_options(global_this, &blob, options)?;

// WebIDL dictionary member: only `undefined` / not-present falls
// through to the Date.now() default. Any present value (including
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// `slice`, `new Blob()` and `new File()` convert a String object with a toString of its own. Nothing else in the program calls
// that toString, and nothing uses the results of the calls. In the place of start or end of `slice`, a String object is the contentType.
let conversions = 0;
const boxed = Object.assign(new String("x/boxed"), {
toString() {
conversions++;
return "x/own";
},
});
const blob = new Blob(["abcd"]);

blob.slice(0, 2, boxed);
console.log(conversions);
blob.slice(boxed);
console.log(conversions);
blob.slice(0, boxed);
console.log(conversions);
new Blob(["a"], { type: boxed });
console.log(conversions);
new File(["a"], "name", { type: boxed });
console.log(conversions);
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// `new Blob()` and `new File()` convert the `type` of their options to a string, and `slice` converts its contentType.
// The conversion runs the code of an object, also in a call whose result nothing uses.
let conversions = 0;
const type = {
toString() {
conversions++;
return "x/converted";
},
};
const blob = new Blob(["abcd"]);

console.log(new Blob(["a"], { type }).type, conversions);
console.log(new File(["a"], "name", { type }).type, conversions);
console.log(blob.slice(0, 2, type).type, conversions);

new Blob(["a"], { type });
console.log(conversions);
blob.slice(0, 2, type);
console.log(conversions);

// A string in the place of start is the contentType, and the operand after it is not read.
console.log(blob.slice("a/b", 1, type).type, conversions);
console.log(blob.slice(0, 2).type === "", blob.slice(0, 2, "A/B").type, blob.slice(0, 2, 1).type);
console.log(blob.slice(0, 2, null).type, blob.slice(0, 2, undefined).type === "");

const options = {
reads: 0,
get type() {
this.reads++;
return ["x/from-a-getter"];
},
};
console.log(new Blob(["a"], options).type, options.reads);

for (const value of [Symbol("type"), Object.create(null)]) {
try {
blob.slice(0, 1, value);
console.log("slice did not throw");
} catch (error) {
console.log(error instanceof TypeError, conversions);
}
try {
new Blob(["a"], { type: value });
console.log("new Blob did not throw");
} catch (error) {
console.log(error instanceof TypeError, conversions);
}
}
Loading
Loading