diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index e3e92b81416..569c4d2ba33 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -2695,8 +2695,13 @@ 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` + // 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 @@ -2704,8 +2709,20 @@ impl BlobExt for Blob { // 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::(buf))); + let unaligned: Vec; + 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); } @@ -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` + // 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::(buf))); + let unaligned: Vec; + 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. diff --git a/test/js/web/fetch/blob.test.ts b/test/js/web/fetch/blob.test.ts index 6b1db37ab93..0f5eda0be33 100644 --- a/test/js/web/fetch/blob.test.ts +++ b/test/js/web/fetch/blob.test.ts @@ -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"; @@ -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); }); @@ -471,3 +473,35 @@ 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 misalignedJson = Buffer.concat([Buffer.from([0x00, 0xFF, 0xFE]), Buffer.from(JSON.stringify({ a: 1 }), "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(); + const mj = await new Blob([misalignedJson]).slice(1).json(); + process.stdout.write(JSON.stringify(j) + "|" + JSON.stringify(t) + "|" + JSON.stringify(e) + "|" + JSON.stringify(m) + "|" + JSON.stringify(mj)); + `; + 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"|{"a":1}`); + expect(exitCode).toBe(0); +});