Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 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
50 changes: 42 additions & 8 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2695,17 +2695,34 @@ impl BlobExt for Blob {

if bom == Some(strings::BOM::Utf16Le) {
let _free = (LIFETIME == Lifetime::Temporary).then(|| TemporaryBytes(raw_bytes));
// Mirrors Zig `bun.reinterpretSlice(u16, buf)`: drop a trailing odd byte
// (`@divTrunc`) so `bytemuck::cast_slice` cannot panic on slop.
// Mirrors Zig `bun.reinterpretSlice(u16, buf)`: `buf` may be
// odd-length (truncated input) or odd-address (`.slice(odd)` of a
// shared store) — either makes `bytemuck::cast_slice` `panic!`
// (uncatchable). Drop the trailing odd byte (`@divTrunc`), then
// borrow as `&[u16]` when already 2-aligned (the common case:
// file reads, whole stores) and only copy into a fresh `Vec<u16>`
// when `try_cast_slice` rejects on alignment.
// +1 WTF ref; `OwnedString` releases it on scope exit (Zig: `defer out.deref()`).
//
// ZIG PARITY: this branch intentionally does NOT `self.detach()` for
// `Lifetime::Transfer` — `Blob.zig` `toStringWithBytes` (UTF-16LE arm)
// returns without detaching, unlike `toJSONWithBytes`. Any change to
// that asymmetry belongs upstream in the Zig source, not here.
let buf = &buf[..buf.len() & !1];
let out =
OwnedString::new(BunString::clone_utf16(bytemuck::cast_slice::<u8, u16>(buf)));
let unaligned: Vec<u16>;
let utf16: &[u16] = match bytemuck::try_cast_slice(buf) {
Ok(s) => s,
Err(_) => {
unaligned = buf
.as_chunks::<2>()
.0
.iter()
.map(|c| u16::from_le_bytes(*c))
.collect();
&unaligned
}
};
let out = OwnedString::new(BunString::clone_utf16(utf16));
return out.to_js(global);
}

Expand Down Expand Up @@ -2920,12 +2937,29 @@ impl BlobExt for Blob {
}

if bom == Some(strings::BOM::Utf16Le) {
// Mirrors Zig `bun.reinterpretSlice(u16, buf)`: drop a trailing odd byte
// (`@divTrunc`) so `bytemuck::cast_slice` cannot panic on slop.
// Mirrors Zig `bun.reinterpretSlice(u16, buf)`: `buf` may be
// odd-length (truncated input) or odd-address (`.slice(odd)` of a
// shared store) — either makes `bytemuck::cast_slice` `panic!`
// (uncatchable). Drop the trailing odd byte (`@divTrunc`), then
// borrow as `&[u16]` when already 2-aligned (the common case:
// file reads, whole stores) and only copy into a fresh `Vec<u16>`
// when `try_cast_slice` rejects on alignment.
// +1 WTF ref; `OwnedString` releases it on scope exit (Zig: `defer out.deref()`).
let buf = &buf[..buf.len() & !1];
let mut out =
OwnedString::new(BunString::clone_utf16(bytemuck::cast_slice::<u8, u16>(buf)));
let unaligned: Vec<u16>;
let utf16: &[u16] = match bytemuck::try_cast_slice(buf) {
Ok(s) => s,
Err(_) => {
unaligned = buf
.as_chunks::<2>()
.0
.iter()
.map(|c| u16::from_le_bytes(*c))
.collect();
&unaligned
}
};
let mut out = OwnedString::new(BunString::clone_utf16(utf16));
// PORT NOTE: Zig used `defer { free; detach }`. Reshaped to compute the
// result first, then perform the deferred work explicitly — capturing
// `&mut self` in a scopeguard closure conflicts with later uses below.
Expand Down
36 changes: 34 additions & 2 deletions test/js/web/fetch/blob.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isASAN, tempDir } from "harness";
import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness";
import type { BlobOptions } from "node:buffer";
import type { BinaryLike } from "node:crypto";
import path from "node:path";
Expand Down Expand Up @@ -350,7 +350,9 @@ test("Bun.file(path, {type}).text() does not leak the duped content_type", async
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const { deltaMiB } = JSON.parse(stdout);
expect(deltaMiB).toBeLessThan(isASAN ? 400 : 40);
// Debug builds (bun-debug is ASAN + debug allocator) inflate RSS the same
// way the named bun-asan CI binary does.
expect(deltaMiB).toBeLessThan(isASAN || isDebug ? 400 : 40);
expect(exitCode).toBe(0);
});

Expand Down Expand Up @@ -471,3 +473,33 @@ test("Blob constructor copies typed array parts before later parts run user code
expect(stdout.trim()).toBe("OK 68");
expect(exitCode).toBe(0);
});

test("Blob.json()/.text() on odd-length/odd-aligned UTF-16LE+BOM does not abort", async () => {
// Odd length: stripping the 2-byte BOM keeps the length odd. Odd address:
// `.slice(1)` of a shared byte store hands an odd pointer straight to the
// u8->u16 cast. Either used to `panic!` and abort the whole process
// (uncatchable). Run in a subprocess: pre-fix it exits 133 with no output;
// fixed it drops the trailing odd byte and reads unaligned like Zig.
const src = `
const oddJson = Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from(JSON.stringify({ a: 1 }), "utf16le"), Buffer.from([0x20])]);
const oddText = Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from("hi", "utf16le"), Buffer.from([0x20])]);
const evenJson = Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from(JSON.stringify({ a: 1 }), "utf16le")]);
// Odd address: pad 1 byte then .slice(1) so the view starts at base+1.
const misaligned = Buffer.concat([Buffer.from([0x00, 0xFF, 0xFE]), Buffer.from("hi", "utf16le")]);
const j = await new Blob([oddJson]).json();
const t = await new Blob([oddText]).text();
const e = await new Blob([evenJson]).json();
const m = await new Blob([misaligned]).slice(1).text();
Comment thread
robobun marked this conversation as resolved.
process.stdout.write(JSON.stringify(j) + "|" + JSON.stringify(t) + "|" + JSON.stringify(e) + "|" + JSON.stringify(m));
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", src],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe(`{"a":1}|"hi"|{"a":1}|"hi"`);
expect(exitCode).toBe(0);
Comment thread
claude[bot] marked this conversation as resolved.
});
Loading