Skip to content

Bun.build: reject a null byte in path options and in output names the build opens - #46752

Open
robobun wants to merge 3 commits into
mainfrom
robobun/2980a617/bun-build-nul-paths
Open

robobun wants to merge 3 commits into
mainfrom
robobun/2980a617/bun-build-nul-paths

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A syscall reads a path only up to its first NUL byte, and Bun.build passes one through. naming: "pre\0-[name].js" with two entry points reports success: true and replaces out/pre.
  • A debug build aborts: panic: ZStr::as_cstr: interior NUL would truncate the C view (bun_sys::write_file_with_path_buffer).

Fix

  • Config::from_js (src/runtime/api/JSBundler.rs) throws ERR_INVALID_ARG_VALUE for a NUL in outdir, tsconfig, root, metafile, four compile paths, and two values it derives from entrypoints.
  • PathTemplate::check_output_path fails the build for a NUL in an output name the build opens. compute_chunks and bun build skip such a directory open.
  • Verified: 7 new tests (bun-build-api.test.ts, cli.test.ts, 6 fail on main) and 12 more bundler suites.
  • Self-reviewed: 22 concerns raised, 18 addressed. Rejected (Notes): bun build --app, a bytecodeOrder index, two process items.

Background

Downsides

  • Behavior change: the options throw, also with throw: false. A plugin-derived name with a NUL fails the build. esbuild renames it.
  • Cost: +151 instructions in Config::from_js (31,521 to 31,672), +71 per asset written, 0 allocations, 0 syscalls, .text +2,048 bytes.
  • Still open: the resolver reads a module path up to a NUL, and a debug build aborts (resolver, process.dlopen: do not open a path that contains a null byte #43341).
Notes

No user reported this. A code read found it. Earlier attempts: #37007 first carried Bun.build checks and split them out to a branch that failed the build in the disk writer. This PR replaces that branch. #40531 (publicPath) was closed because that value reaches no syscall.

Questions for a maintainer (from the comment on #37007, with the answers this PR picks)

  1. A NUL in outdir: throw when the options are parsed, or fail the build? This PR throws, as node:fs, Bun.file, Bun.spawn and FileSystemRouter (Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021) do. esbuild 0.21.5 fails the build (mkdir ...: invalid argument). The Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 test for a too-long compile.outfile expects a failed build.
  2. An output name from a plugin path or a files key with a NUL: fail the build, or rename? This PR fails the build. esbuild 0.21.5 writes virtual_a_b.js. Windows Bun Bundler output filename corrupted #25546 asks the same question for : on Windows. A rename needs a choice of spelling and of whether in-memory names change too.
  3. Should the bun_sys helpers that copy a slice into a C string return an errno for a NUL? Not changed here. Some writers build their own C string (.map files, assets, the metafile), so that check alone does not close this.

Where each check is

  • Thrown from the Bun.build() call as TypeError [ERR_INVALID_ARG_VALUE], before anything is opened: outdir, tsconfig, root, metafile, metafile.json, metafile.markdown, compile.outfile, compile.executablePath, compile.windows.icon, compile.bytecodeOrder.
  • The same throw for two values that come from entrypoints: the root directory when root is not set, and the executable name when compile.outfile is not set. The message names the value and the option that overrides it.
  • A build error in logs, before the first write: naming in its four forms, and a name taken from a files key or a plugin path. A standalone HTML build checks the HTML name and .map names. An executable checks .map basenames. Both embed the other names.
  • bun build: a root directory with a NUL is an error. Only entryPoints in bunfig.toml can carry one, because an argument cannot.
  • Valid::no_null_bytes now says "property" for a dotted name, as Node's ERR_INVALID_ARG_VALUE does. Its error path moved to a cold function. The 10 new callers had stopped the compiler from inlining it into the fs path parser (243 to 258 instructions in path_like_from_string). It is 239 now.

Outcome changes (release build of main a8fcd31 against this branch, 28 test rows)

  • Success to a throw: outdir, the three metafile forms, compile.outfile. Also tsconfig, root, compile.windows.icon and compile.bytecodeOrder when the path cut at the NUL exists.
  • Success to a failed build: the 13 output-name rows.
  • An error to another error: entrypoints through the root directory (main: ModuleNotFound, or ENOENT: failed to open root directory when the cut directory is missing), compile.executablePath (main: failed to write compiled executable).
  • Name only: [dir] without an outdir is now sub\0tail/virtual.js. Main returned sub/virtual.js, the name of another directory.
  • Not changed: a name without an outdir, a plugin path or files key inside a chunk, publicPath, banner, an entry point that does not exist (ModuleNotFound), names that a standalone HTML build inlines or an executable embeds. compile.assets already rejects a NUL.
  • A metafile path with a NUL now throws in an executable build with no outdir. That build never opened it.

Still open in Bun.build (run here on release main and on a debug build of this branch)

Route Release main Debug, this branch Owner
A plugin onResolve path in the file namespace reads the file at the cut path, success: true aborts in ZStr::as_cstr #43341
extends in an on-disk tsconfig.json reads the cut path, success: true aborts #43341
A files key in a directory whose name has a NUL, with a relative import EISDIR reading file aborts #43341
check: true with a plugin module path (bun_sys::exists, src/bundler/bundle_v2.rs) TS6053: File ... not found the same none
bun build --app, a plugin asset path pic.p\0ng writes dist/_bun/<hash>.p aborts none
BUN_FEATURE_FLAG_DUMP_CODE (canary and debug builds), an embedded chunk ch\0unk-lazy.js writes the dump as ch aborts none

#43341 has the resolver checks and needs a rebase. The compile.windows strings other than icon are not paths and are not checked.

Rejected from the self-review

  • bun build --app: its writer has no outdir, so check_output_path does not run for it. It writes client files only, so the check needs the side of each output. It would also turn on the too-long check for that command.
  • An element index in the compile.bytecodeOrder error: the two other errors of that parser have none, and the message shows the value.
  • A tracking issue for the rows above that have no owner: not filed, the table is the record. I did not run the same class in APIs outside Bun.build.
  • A run on Windows: not possible here. The facts above are from Linux x64. The checks have no platform code.

Overlaps

Measurements (release builds of main a8fcd31 and this branch, Linux x64. perf, valgrind and strace are not installed here, so the counts are gdb single-step counts and gdb syscall catchpoints.)

  • Config::from_js, { entrypoints, outdir }: +151 instructions (31,521 to 31,672). No outdir: +79 (30,940 to 31,019). Nine path options set: +619 (51,599 to 52,218). Allocator entries: 58, 56 and 97 on both.
  • check_output_path: 69 or 83 instructions per output name. On main it is an inlined length compare. process_files_to_copy with one asset: 7,809 to 7,880 with an outdir, no change without one (7,785 to 7,812 against 7,795 to 7,798 over three runs).
  • compute_chunks: one more scan per entry chunk that uses [dir], 28 instructions for a 30-byte path.
  • Path syscalls of valid builds: 49 (disk), 42 (in memory) and 58 (splitting) on both, plus or minus one: an openat of the project directory comes and goes between runs of the same binary.
  • The 28 rows with a NUL: 33 syscalls on a path cut at the NUL on main, 0 on this branch. Main creates 25 files and directories, this branch none.
  • Binary: .text +2,048 bytes (82,257,737 to 82,259,785), .rodata +0. 19 symbols differ, net +1,920 bytes.

Tests

  • bun bd test test/bundler/bun-build-api.test.ts -t "NUL byte": 6 pass. cli.test.ts -t "bunfig.toml with a NUL": 1 pass.
  • A release build of main as the runner: 5 of the 6 fail, and the cli.test.ts test fails. The sixth test pins the names that an executable does not check. It passes on both.
  • A release build of this branch as the runner: bun-build-api 95 pass, cli 44 pass, and 0 failures in bundler_naming, metafile, bundler_files, bundler_plugin, bundler_splitting, bundler_html, standalone, bun-build-compile-sourcemap, bundler_compile_splitting, html-import-manifest, bundler_edgecase, bundler_compile.
  • The debug build of this branch: bun-build-api 93 pass and 3 timeouts (bytecode: function record on an encoder page boundary, bytecode: repeated builds don't retain the generated code, Bun.build can be called thousands of times in one process without crashing). cli has 3 timeouts in tests that compile an executable. Each passes on the release builds. bundler_naming, metafile, bundler_files, html-import-manifest, bundler_html, filesystem_router, mmap and test-fs-null-bytes.js pass.
  • A debug build of main aborts on the first build of the repro with the panic above.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/cli.test.ts, test/bundler/bun-build-api.test.ts

… build opens

A syscall reads a path only up to its first NUL byte, so a NUL in outdir,
naming, metafile, compile.outfile, root or tsconfig made Bun.build open a
different path than the one the caller named. A release build reported
success and wrote to the cut path. A debug build on Linux aborted in
ZStr::as_cstr.

Config::from_js now throws ERR_INVALID_ARG_VALUE for a NUL in a path option
that the build opens as given, and for a root directory or an executable
name that it takes from the entry points. PathTemplate::check_output_path
fails the build for a NUL in the part of an output name that the build
opens. compute_chunks no longer opens a [dir] path that has a NUL, and
bun build no longer opens a root directory that has one.
@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:08 AM PT - Oct 24th, 2026

✅ @robobun, your commit 655d78ee65a3ba199d5612a2a8e2c0b8315baed4 passed in Build #132062! 🎉


🧪   To try this PR locally:

bunx bun-pr 46752

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

bun-46752 --bun

@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduction on 1.4.3-canary.1 and on a release build of main a8fcd31 (Linux x64):

import fs from "node:fs";
fs.writeFileSync("a.ts", "console.log('A')");
fs.writeFileSync("b.ts", "console.log('B')");
fs.mkdirSync("out", { recursive: true });
fs.writeFileSync("out/pre", "PRECIOUS");
const build = await Bun.build({
  entrypoints: ["./a.ts", "./b.ts"],
  outdir: "out",
  naming: "pre\0-[name].js",
  throw: false,
});
console.log(build.success, build.logs.map(log => log.message));
console.log(fs.readdirSync("out"), fs.readFileSync("out/pre", "utf8").trim());

Main prints true [], and out/pre is the only file. It now holds the bundle of b.ts. With outdir: "out2\0tail" main writes into out2.

A debug build of main aborts on the script with panic: ZStr::as_cstr: interior NUL would truncate the C view.

With this PR the script prints false and one Output path for "a.ts" contains a null byte: "./pre\u0000-a.js" log for each entry point, and out/pre still holds PRECIOUS. The outdir case throws ERR_INVALID_ARG_VALUE.

PR: #46752

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 978c173 and 655d78e.

Files selected for processing: 4
  • src/bundler/linker_context/generateChunksInParallel.rs
  • src/bundler/options.rs
  • src/runtime/api/JSBundler.rs
  • test/bundler/bun-build-api.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

The build API and CLI now validate NUL bytes in path values. Bundler output-path checks account for compile mode and whether a path will be opened. Regression tests cover rejected paths and builds that use NUL-containing names without opening them.

Changes

NUL Byte Path Validation

Layer / File(s) Summary
Input path validation
src/runtime/node/types.rs, src/runtime/api/JSBundler.rs, src/runtime/cli/build_command.rs, test/bundler/bun-build-api.test.ts, test/bundler/cli.test.ts
Build API options and selected root paths receive NUL-byte checks. Tests cover invalid path options, derived paths, and CLI root-directory errors.
Compile-mode-aware output path validation
src/bundler/options.rs, src/bundler/linker_context/computeChunks.rs, src/bundler/linker_context/generateChunksInParallel.rs, src/bundler/bundle_v2.rs, test/bundler/bun-build-api.test.ts
Output validation checks the path portion that will be opened. Compile mode determines which paths are checked; tests cover failures and successful builds when names are not opened.

Suggested reviewers: jarred-sumner, dylan-conway, alii

Priority: Normal

Merge Risk: Low · up to 655d7

The change is mergeable with owner awareness: an opt-in debug code dump can overwrite a dump file when a generated name contains a NUL byte.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title names the main change: Bun.build now rejects NUL bytes in path options and in output names the build opens. The raw summary and PR objectives confirm the changeset covers this, with checks i…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description is complete and relevant. It explains the problem, implementation, behavior changes, limitations, and verification results. It does not use the template headings exactly, but it provid…
Full details: Title check

Explanation

The title names the main change: Bun.build now rejects NUL bytes in path options and in output names the build opens. The raw summary and PR objectives confirm the changeset covers this, with checks in JSBundler, options.rs, generateChunksInParallel, computeChunks, and the CLI. The title is specific and concise, and it fits the 'Bun.build:' prefix convention. It is a single sentence of 15 words, so it is slightly long but still readable.

Full details: Description check

Explanation

The description is complete and relevant. It explains the problem, implementation, behavior changes, limitations, and verification results. It does not use the template headings exactly, but it provides the required information under equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the Valid::no_null_bytes wording change ("argument" to "property" for dotted names): the only pre-existing callers are path (src/runtime/node/types.rs) and dir (src/runtime/api/filesystem_router.rs), neither dotted, so no existing fs/FileSystemRouter message or test assertion changes. The three check_output_path / [dir] / bunfig root sites all route the byte scan through bun_core::strings::contains_char with the alias already imported in each file.

Extended reasoning...

The change adds NUL-byte rejection across Bun.build option parsing (src/runtime/api/JSBundler.rs), the output-path check in src/bundler/options.rs and its two callers, the [dir] directory open in computeChunks.rs, and the bunfig-derived root in build_command.rs, plus ~430 lines of tests. It touches path-to-syscall validation, which is the security-relevant surface here, and the inline findings already flag the remaining uncovered writer and a test-convention nit, so a human look is warranted rather than approval.

Comment thread test/bundler/bun-build-api.test.ts Outdated
Comment thread src/bundler/bundle_v2.rs
… they copy the executable

The two builds only have to pass the output name check. An asset that does
not exist stops each one right after it, so the test writes no executable
and needs no per-test timeout.
Comment thread src/bundler/linker_context/generateChunksInParallel.rs Outdated
Comment thread src/bundler/options.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Changes since the first review:

  • a837bd9: the executable test sets no timeout. Each of its two builds names an asset that does not exist, which stops the build after the output name check and before it copies the executable.
  • 655d78e: the three two-line comments are one line each.

Not changed: the bun build --app writer. It has no outdir, so check_output_path does not run for it, and it opens client files only. The route is in the table under "Still open in Bun.build" in the description.

@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.

I re-reviewed the latest push and found no bugs; the executable test now stops at the missing compile.assets entry instead of copying the binary, which resolves the timeout nit. A human look is still worthwhile because the PR deliberately picks answers to open API questions (throwing from option parsing even with throw: false, failing the build rather than renaming a plugin-derived output name) that a maintainer should confirm.

What was reviewed:

  • The opened slice chosen per compile mode in generateChunksInParallel.rs against the actual writers: executable .map files are written by basename(dest_path) in js_bundle_completion_task.rs, and has_external_files() is exactly Linked|External, so the checked slice matches what is opened.
  • The reworked test's ordering claim: collect_compile_assets runs in do_compilation after bundling, so check_output_path runs first and the test can still tell "name not checked" from "name rejected".
  • The remaining variant matrix (executable .map basename, standalone HTML .map and HTML name, names never opened) is still asserted with exact messages in the sibling tests.
Extended reasoning...

The change adds NUL-byte validation to Bun.build option parsing (outdir, tsconfig, root, metafile, compile paths, bytecodeOrder, and two entrypoint-derived values), a NUL check on the opened portion of output names in check_output_path with per-compile-mode selection of that portion, a guarded directory open in compute_chunks, and a bunfig-derived root check in the CLI, plus about 430 lines of tests. The security-relevant surface is path handling before syscalls, which the change tightens rather than loosens. Since the prior review only one test and three comments changed, the earlier timeout nit is addressed, and no third-party objections appear in the timeline. Not approved because the PR description itself lists design questions for a maintainer (throw vs fail-build, fail vs rename) and the behavior change under throw: false is a user-facing API decision.

@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

About the code dump in the merge risk note: BUN_FEATURE_FLAG_DUMP_CODE works in canary and debug builds only, and this PR does not change it. With the variable set, a canary build writes the dump of an embedded chunk ch\0unk-lazy.js as ch, and a debug build aborts in ZStr::as_cstr (src/standalone_graph/StandaloneModuleGraph.rs). Main does the same. The route is now a row in the table under "Still open in Bun.build" in the description.

This branch has not been deployed

No deployments
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