Skip to content

plugin: preserve raw bytes when onLoad/build.module returns an ArrayBufferView - #35729

Open
robobun wants to merge 1 commit into
mainfrom
farm/0198331d/plugin-buffer-contents-utf8
Open

plugin: preserve raw bytes when onLoad/build.module returns an ArrayBufferView#35729
robobun wants to merge 1 commit into
mainfrom
farm/0198331d/plugin-buffer-contents-utf8

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Runtime Bun.plugin callbacks (build.module(...) and onLoad(...)) that return contents as a Buffer/Uint8Array had any byte >= 0x80 expanded to its two-byte UTF-8 encoding before the loader saw it.

Bun.plugin({
  setup(build) {
    build.module("virtual:x", () => ({
      contents: Buffer.from('export default "é"', "utf8"),
      loader: "js",
    }));
  },
});
(await import("virtual:x")).default;
// before: "é"  (bytes c3 a9 became c3 83 c2 a9)
// after:  "é"

For binary contents (e.g. a .wasm buffer supplied through a plugin, as in #35587) every high byte shifted the rest of the stream, which surfaces as a parse error at the wrong offset.

Why

handleOnLoadResultNotPromise built the ZigString for the ArrayBufferView case with no encoding tag:

result.value.sourceText.string =
    ZigString { reinterpret_cast<const unsigned char*>(view->vector()), view->byteLength() };

An untagged ZigString is Latin-1 to ZigString::to_slice() in transpile_virtual_module, which re-encodes any non-ASCII byte as UTF-8. A typed array of source bytes is already UTF-8 (or opaque binary), so that transcode is data corruption.

The string branch right above it goes through toZigString(JSString*, ...), which correctly tags Latin-1/UTF-16, so the transcode there is intentional and is left alone.

Fix

Tag the ArrayBufferView-backed pointer with the UTF-8 bit so to_slice() borrows the bytes verbatim. Adds a taggedUTF8Ptr() helper alongside the existing taggedUTF16Ptr() in helpers.h.

Tests

test/js/bun/plugin/plugins.test.ts gains four cases covering build.module and onLoad, each sync and async, asserting that a UTF-8 é in a Buffer round-trips unchanged. All four fail on main with "é".


[review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/plugin/plugins.test.ts
bun test v1.4.0 (4523f8d5f)

test/js/bun/plugin/plugins.test.ts:
If bundling, conditions should include development or production. If not bundling, conditions or NODE_ENV should include development or production. See https://www.npmjs.com/package/esm-env for tips on setting conditions in popular bundlers and runtimes.
(pass) require > SSRs `<h1>Hello world!</h1>` with Svelte [1199.57ms]
(pass) require > beep:boop returns 42 [9.96ms]
(pass) require > object module works [9.33ms]
(pass) module > throws with require() [13.37ms]
(pass) module > async module works with async import [26.85ms]
(pass) module > sync module module works with require() [4.87ms]
(pass) module > sync module module works with require.resolve() [3.20ms]
(pass) module > sync module module works with import [5.23ms]
(pass) module > modules are overridable [18.95ms]
342 |     ["build.module async", "buffer-module-async"],
343 |     ["onLoad sync", "buffer-contents:sync"],
344 |     ["onLoad async", "buffer-contents:async"],
345 |   ])(
... (truncated)

release without fix: 7 FAILED
bun test v1.3.14 (0d9b296a)

test/js/bun/plugin/plugins.test.ts:
If bundling, conditions should include development or production. If not bundling, conditions or NODE_ENV should include development or production. See https://www.npmjs.com/package/esm-env for tips on setting conditions in popular bundlers and runtimes.
(pass) require > SSRs `<h1>Hello world!</h1>` with Svelte [37.52ms]
(pass) require > beep:boop returns 42 [0.40ms]
(pass) require > object module works [0.14ms]
(pass) module > throws with require() [2.39ms]
(pass) module > async module works with async import [3.13ms]
(pass) module > sync module module works with require() [0.13ms]
(pass) module > sync module module works with require.resolve() [0.06ms]
(pass) module > sync module module works with import [0.10ms]
(pass) module > modules are overridable [2.43ms]
342 |     ["build.module async", "buffer-module-async"],
343 |     ["onLoad sync", "buffer-contents:sync"],
344 |     ["onLoad async", "buffer-contents:async"],
345 |   ])("passes raw bytes through for %s", async (_, specifier) => {
346 |     const { default: value } = await import(specifier);
347 |     expect(value).toBe("é");
              
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/plugin/plugins.test.ts
bun test v1.4.0 (4523f8d5f)

test/js/bun/plugin/plugins.test.ts:
If bundling, conditions should include development or production. If not bundling, conditions or NODE_ENV should include development or production. See https://www.npmjs.com/package/esm-env for tips on setting conditions in popular bundlers and runtimes.
(pass) require > SSRs `<h1>Hello world!</h1>` with Svelte [1166.28ms]
(pass) require > beep:boop returns 42 [10.15ms]
(pass) require > object module works [9.19ms]
(pass) module > throws with require() [13.84ms]
(pass) module > async module works with async import [29.84ms]
(pass) module > sync module module works with require() [4.73ms]
(pass) module > sync module module works with require.resolve() [3.58ms]
(pass) module > sync module module works with import [5.58ms]
(pass) module > modules are overridable [89.70ms]
(pass) typed array contents > passes raw bytes through for build.module sync [13.75ms]
(pass) typed array contents > passes raw bytes through for build.module async [9.89m
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     4523f8d5fd
  features     baseline

22 deps, 108 codegen, 1171 objects in 1863ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] mkdir codegen
[2/1238] mkdir stamps
[3/1238] mkdir pch
[4/1238] mkdir obj
[5/1238] gen ErrorCode+*.h
[6/1238] fetch picohttpparser
[picohttpparser] up to date
[7/1238] gen bindgenv2
[8/1238] fetch zlib
[zlib] up to date
[9/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1238] fetch tinycc
[tinycc] up to date
[11/1238] gen .bind.ts → GeneratedBindings.cpp
[12/1238] subst deps/zlib/zlib.h
[13/1238] fetch nodejs (prebuilt)
[nodejs] up to date
[14/1238] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[15/1238] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingHTTPParser.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingHTTPParser.cpp
[16/1238] subst deps/zlib/zconf.h
[17/1238] fetch zstd
[zstd] up t
... (truncated)
diff hotspot
src/jsc/bindings/ModuleLoader.cpp  |  5 ++++-
 src/jsc/bindings/helpers.h         |  5 +++++
 test/js/bun/plugin/plugins.test.ts | 46 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 55 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                reads  edits  tests
src/jsc/bindings/ModuleLoader.cpp       2      1      0
src/jsc/bindings/helpers.h              1      1      0
test/js/bun/plugin/plugins.test.ts      2      2      0

self-review · no surviving concerns

32 concerns were raised and did not survive verification.

…ufferView

When a runtime plugin's onLoad or build.module callback returns
{ contents: <Buffer|Uint8Array>, ... }, the C++ side wrapped the raw
bytes in a ZigString with no encoding tag. An untagged ZigString is
interpreted as Latin-1 by ZigString::to_slice(), which transcodes any
byte >= 0x80 into its multi-byte UTF-8 encoding before the transpiler
sees it. For UTF-8 source that means double-encoding (an 'é' in the
buffer reaches the parser as 'é'); for binary payloads every high
byte shifts the rest of the stream.

Tag the ArrayBufferView-backed ZigString as UTF-8 so the bytes pass
through to the loader verbatim. Adds a taggedUTF8Ptr() helper next to
the existing taggedUTF16Ptr().

String contents are unaffected: those already carry a correct Latin-1
or UTF-16 tag from toZigString(JSString*), and the transcode there is
intentional.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cd5796c3-2289-4b4d-92f8-7ba634e14d5e

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 4523f8d.

📒 Files selected for processing (3)
  • src/jsc/bindings/ModuleLoader.cpp
  • src/jsc/bindings/helpers.h
  • test/js/bun/plugin/plugins.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:28 AM PT - Jul 25th, 2026

@robobun, your commit 4523f8d has 1 failures in Build #80921 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35729

That installs a local version of the PR into your bun-35729 executable, so you can run:

bun-35729 --bun

@claude claude Bot left a comment

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.

LGTM — one-line encoding-tag fix with matching helper and tests.

What was reviewed:

  • Verified bit 61 in taggedUTF8Ptr matches ZS_UTF8_BIT in bun_alloc/lib.rs and isTaggedUTF8Ptr in helpers.h.
  • Traced ZigString::to_slice() (src/bun_core/string/mod.rs:1694): with the UTF-8 bit set it borrows bytes verbatim; without it, Latin-1→UTF-8 transcodes — confirms the root cause.
  • Checked for sibling ZigString { view->vector(), byteLength() } sites — this is the only one.
  • Empty/zero-length buffers: to_slice() returns early on len == 0 before touching the tagged pointer.
Extended reasoning...

Overview

Single functional line in src/jsc/bindings/ModuleLoader.cpp: when a runtime plugin's onLoad/build.module returns contents as an ArrayBufferView, the ZigString wrapping those bytes is now tagged with the UTF-8 bit (bit 61) instead of being left untagged. A 4-line taggedUTF8Ptr() helper is added in helpers.h alongside the existing taggedUTF16Ptr(). Four new test cases in the existing plugins.test.ts cover sync/async × build.module/onLoad.

Correctness

I verified the bit constant end-to-end: taggedUTF8Ptr sets 1 << 61; the pre-existing isTaggedUTF8Ptr in the same header reads 1 << 61; and on the Rust side ZS_UTF8_BIT = 1usize << 61 in src/bun_alloc/lib.rs. ZigString::to_slice() (src/bun_core/string/mod.rs:1694) branches on is_utf8(): when set, it returns ZigStringSlice::Static borrowing the raw bytes; when unset (the pre-PR state), it calls to_utf8_from_latin1() which expands every byte ≥ 0x80 to two bytes — exactly the reported corruption. The fix is precisely at the layer that owns the invariant (encoding tag on the ZigString), and the string branch above it is intentionally left alone since JSString-sourced ZigStrings are genuinely Latin-1/UTF-16.

Security risks

None. This narrows an over-eager transcode; no new parsing, no new trust boundaries, no size arithmetic changes. The tagged pointer with a null vector is safe because to_slice() returns EMPTY when len == 0 before dereferencing.

Level of scrutiny

Low-to-medium. It touches C++/Rust FFI string tagging in the module loader, but the change is a single well-understood bit flag whose semantics are already established on both sides of the FFI boundary and already used elsewhere. I grepped for other ZigString { ...view->vector()... } construction sites to check the whole bug class — this is the only one.

Other factors

Tests are placed in the existing plugin test file per repo convention, use it.each for the 4-case matrix, assert exact values (both the string and its UTF-8 bytes), and per the PR description all four fail on main with "é". No outstanding reviewer comments.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI on build 80921: the new plugins.test.ts cases pass on every lane. The remaining red are all tagged flaky/pre-existing against main and unrelated to this diff (password leak threshold, bun-link timing on win-aarch64, bake css-14, complex-workspace migration, jsonwebtoken async_sign, webview-chrome, plus the standing freebsd build step). None touch the module loader or plugin path.

Diff is ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant