diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 201b32587eeb..3536104cc369 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4875,6 +4875,12 @@ pub mod bv2_impl { self.transpiler.log_mut(), source.path.pretty, &output_path, + match self.transpiler.options.compile_mode { + options::CompileMode::None => &output_path, + // An executable embeds its assets and a standalone HTML build inlines them. + options::CompileMode::Executable + | options::CompileMode::StandaloneHtml => b"", + }, ) { has_unusable_output_path = true; diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index f0cef9bba964..5e3a7d622fc9 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -721,12 +721,19 @@ pub(crate) fn compute_chunks( }; let mut real_path_buf = bun_paths::path_buffer_pool::get(); let dir: &[u8] = 'dir: { - let Ok(dir_file) = bun_sys::File::openat( - bun_sys::Fd::cwd(), - dir_path, - bun_sys::O::PATH | bun_sys::O::DIRECTORY, - 0, - ) else { + // With a NUL byte the open would read the path only up to it, and name another directory. + let dir_file = if strings::contains_char(dir_path, 0) { + None + } else { + bun_sys::File::openat( + bun_sys::Fd::cwd(), + dir_path, + bun_sys::O::PATH | bun_sys::O::DIRECTORY, + 0, + ) + .ok() + }; + let Some(dir_file) = dir_file else { let Some(normalized) = resolve_path::normalize_buf_checked::< bun_paths::platform::Auto, >(dir_path, &mut real_path_buf.0) else { diff --git a/src/bundler/linker_context/generateChunksInParallel.rs b/src/bundler/linker_context/generateChunksInParallel.rs index 9e3251214021..b2ec22ade450 100644 --- a/src/bundler/linker_context/generateChunksInParallel.rs +++ b/src/bundler/linker_context/generateChunksInParallel.rs @@ -433,13 +433,29 @@ pub(crate) fn generate_chunks_in_parallel( [chunk.entry_point.source_index() as usize] .path .pretty; - if opens_output_paths - && !chunk + if opens_output_paths { + // Inlined and embedded chunks are not opened, but their `.map` files are: an executable's by basename. + let has_map = chunk + .content + .sourcemap(c.options.source_maps) + .has_external_files(); + let opened: &[u8] = match c.options.compile_mode { + options::CompileMode::None => &rel_path, + options::CompileMode::StandaloneHtml + if has_map || matches!(chunk.content, crate::chunk::Content::Html) => + { + &rel_path + } + options::CompileMode::Executable if has_map => path::basename(&rel_path), + options::CompileMode::StandaloneHtml | options::CompileMode::Executable => b"", + }; + if !chunk .template - .check_output_path(c.log_disjoint(), input, &rel_path) - { - has_unusable_output_path = true; - continue; + .check_output_path(c.log_disjoint(), input, &rel_path, opened) + { + has_unusable_output_path = true; + continue; + } } let claimed = path_names_map.get_or_put(&rel_path)?; diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 2fe2aff4ccc3..4a53e1c713ee 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2495,13 +2495,28 @@ impl PathTemplate { /// Max output path length. A `PathBuffer` also needs room for a `.map` or `.jsc` suffix and the NUL. pub(crate) const MAX_OUTPUT_PATH_LEN: usize = bun_paths::MAX_PATH_BYTES - 1 - ".map".len(); - /// Logs an error and returns `false` when `output_path` exceeds `MAX_OUTPUT_PATH_LEN`. + /// Logs an error and returns `false` if `output_path` is too long or `opened`, the part the build opens, has a NUL. pub(crate) fn check_output_path( &self, log: &mut bun_ast::Log, input: &[u8], output_path: &[u8], + opened: &[u8], ) -> bool { + if strings::contains_char(opened, 0) { + log.add_range_error_fmt_with_note( + None, + bun_ast::Range::NONE, + format_args!( + "Output path for {} contains a null byte: {}", + bun_core::fmt::quote(input), + bun_core::fmt::quote(output_path), + ), + format_args!("naming template is {}", bun_core::fmt::quote(&self.data)), + bun_ast::Range::NONE, + ); + return false; + } if output_path.len() <= Self::MAX_OUTPUT_PATH_LEN { return true; } diff --git a/src/runtime/api/JSBundler.rs b/src/runtime/api/JSBundler.rs index 7fc1148e4ac0..97f728aa16ab 100644 --- a/src/runtime/api/JSBundler.rs +++ b/src/runtime/api/JSBundler.rs @@ -27,12 +27,34 @@ use bun_bundler_jsc::options_jsc::{compile_target_from_js, compile_target_from_s pub(crate) mod js_bundler { use super::*; + use crate::node::types::Valid; use bun_core::Utf8Bytes; use bun_sys::FdExt; type OwnedString = MutableString; + /// Throws if `value`, which comes from `entrypoints` unless `option` is set, has a NUL byte. + fn derived_no_null_bytes( + global: &JSGlobalObject, + what: &str, + value: &[u8], + option: &str, + ) -> JsResult<()> { + if !bun_core::strings::contains_char(value, 0) { + return Ok(()); + } + Err(global + .err( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!( + "The {what} {}, taken from 'entrypoints', must not contain null bytes. Set '{option}' to override it.", + bun_core::fmt::quote(value) + ), + ) + .throw()) + } + /// `options::JSX::Runtime` → `api::JsxRuntime` (only the reverse `From` /// exists upstream). fn jsx_runtime_to_api(r: options::JSX::Runtime) -> api::JsxRuntime { @@ -341,6 +363,12 @@ pub(crate) mod js_bundler { object.get_own(global_this, &BunString::static_("executablePath"))? { let slice = executable_path.to_utf8(global_this)?; + Valid::no_null_bytes( + slice.slice(), + "compile.executablePath", + "a string", + global_this, + )?; let path_z = bun_core::ZBox::from_bytes(slice.slice()); if bun_sys::exists_at_type(bun_sys::Fd::cwd(), path_z.as_zstr()) .unwrap_or(bun_sys::ExistsAtType::Directory) @@ -370,6 +398,12 @@ pub(crate) mod js_bundler { windows.get_own(global_this, &BunString::static_("icon"))? { let slice = windows_icon_path.to_utf8(global_this)?; + Valid::no_null_bytes( + slice.slice(), + "compile.windows.icon", + "a string", + global_this, + )?; let path_z = bun_core::ZBox::from_bytes(slice.slice()); if bun_sys::exists_at_type(bun_sys::Fd::cwd(), path_z.as_zstr()) .unwrap_or(bun_sys::ExistsAtType::Directory) @@ -421,6 +455,7 @@ pub(crate) mod js_bundler { if let Some(outfile) = object.get_own(global_this, &BunString::static_("outfile"))? { let slice = outfile.to_utf8(global_this)?; + Valid::no_null_bytes(slice.slice(), "compile.outfile", "a string", global_this)?; this.outfile.append_slice_exact(slice.slice())?; } @@ -475,6 +510,12 @@ pub(crate) mod js_bundler { "compile.bytecodeOrder must not contain an empty path" ))); } + Valid::no_null_bytes( + slice.slice(), + "compile.bytecodeOrder", + "a string", + global_this, + )?; this.bytecode_order.push(Box::from(slice.slice())); Ok(()) }; @@ -742,12 +783,14 @@ pub(crate) mod js_bundler { let mut has_out_dir = false; if let Some(slice) = config.get_optional_slice(global_this, b"outdir")? { + Valid::no_null_bytes(slice.slice(), "outdir", "a string", global_this)?; this.outdir.append_slice_exact(slice.slice())?; has_out_dir = true; drop(slice); } if let Some(slice) = config.get_optional_slice(global_this, b"tsconfig")? { + Valid::no_null_bytes(slice.slice(), "tsconfig", "a string", global_this)?; this.tsconfig_override.append_slice_exact(slice.slice())?; drop(slice); } @@ -1004,8 +1047,11 @@ pub(crate) mod js_bundler { } { + let given = config.get_optional_slice(global_this, b"root")?; + let is_default = given.is_none(); let path: Utf8Bytes = 'brk: { - if let Some(slice) = config.get_optional_slice(global_this, b"root")? { + if let Some(slice) = given { + Valid::no_null_bytes(slice.slice(), "root", "a string", global_this)?; break 'brk slice; } @@ -1041,6 +1087,9 @@ pub(crate) mod js_bundler { .unwrap_or(b"."), ); }; + if is_default { + derived_no_null_bytes(global_this, "root directory", path.slice(), "root")?; + } let dir = match bun_sys::open_dir_at(bun_sys::Fd::cwd(), path.slice()) { Ok(d) => d, @@ -1291,18 +1340,31 @@ pub(crate) mod js_bundler { // metafile: "path/to/meta.json" - shorthand for { json: "..." } this.metafile = true; let slice = metafile_value.to_utf8(global_this)?; + Valid::no_null_bytes(slice.slice(), "metafile", "a string", global_this)?; this.metafile_json_path.append_slice_exact(slice.slice())?; drop(slice); } else if metafile_value.is_object() { // metafile: { json?: string, markdown?: string } this.metafile = true; if let Some(slice) = metafile_value.get_optional_slice(global_this, b"json")? { + Valid::no_null_bytes( + slice.slice(), + "metafile.json", + "a string", + global_this, + )?; this.metafile_json_path.append_slice_exact(slice.slice())?; drop(slice); } if let Some(slice) = metafile_value.get_optional_slice(global_this, b"markdown")? { + Valid::no_null_bytes( + slice.slice(), + "metafile.markdown", + "a string", + global_this, + )?; this.metafile_markdown_path .append_slice_exact(slice.slice())?; drop(slice); @@ -1384,6 +1446,12 @@ pub(crate) mod js_bundler { return Err(global_this.throw_invalid_arguments(format_args!("cannot use compile with an output file named 'bun' because bun won't realize it's a standalone executable. Please choose a different name for compile.outfile"))); } + derived_no_null_bytes( + global_this, + "executable name", + outfile, + "compile.outfile", + )?; compile.outfile.append_slice_exact(outfile)?; } } diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 02697be05537..2f378fc47dd4 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -442,6 +442,15 @@ impl BuildCommand { resolve_path::get_if_exists_longest_common_path(&entries).unwrap_or(b".") }; + // An entry point from bunfig.toml can have a NUL byte, and the open would read the path only up to it. + if strings::contains_char(path, 0) { + bun_core::pretty_errorln!( + "error: root directory {} contains a null byte", + bun_fmt::quote(path), + ); + Global::exit(1); + } + // `bun_sys::Dir` is the owning RAII // handle; its Drop closes the fd when this block ends. let dir = match bun_sys::open_dir_at(Fd::cwd(), path) { diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index 5f49abe57865..40ba149257d7 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -1245,6 +1245,8 @@ impl Valid { Self::no_null_bytes(slice, "path", "a string, Uint8Array, or URL", global) } + /// Inlined down to the scan: the `fs` functions run it on every path. + #[inline] pub(crate) fn no_null_bytes( slice: &[u8], name: &str, @@ -1254,15 +1256,31 @@ impl Valid { if strings::index_of_char(slice, 0).is_none() { return Ok(()); } - Err(global + Err(Self::throw_null_bytes(slice, name, accepts, global)) + } + + #[cold] + fn throw_null_bytes( + slice: &[u8], + name: &str, + accepts: &str, + global: &JSGlobalObject, + ) -> jsc::JsError { + // Like Node's ERR_INVALID_ARG_VALUE: a dotted name is a property. + let kind = if strings::contains_char(name.as_bytes(), b'.') { + "property" + } else { + "argument" + }; + global .err( jsc::ErrorCode::INVALID_ARG_VALUE, format_args!( - "The argument '{name}' must be {accepts} without null bytes. Received {}", + "The {kind} '{name}' must be {accepts} without null bytes. Received {}", bun_fmt::quote(slice) ), ) - .throw()) + .throw() } } diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index c7ca18450820..7e6e9c5a904e 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -2687,3 +2687,409 @@ describe.concurrent("Bun.build with a path longer than the path buffer", () => { expect({ stdout, stderr, exitCode }).toEqual({ stdout: "false\n", stderr: "", exitCode: 0 }); }); }); + +// A syscall reads a path only up to its first NUL byte. Each case runs in a child: without the checks, a debug build +// aborts in `ZStr::as_cstr` and a release build opens the path cut at the NUL. +describe.concurrent("Bun.build with a NUL byte in a path", () => { + async function run(script: string) { + using dir = tempDir("bun-build-nul", { + "entry.ts": `console.log("entry");`, + "split.ts": `export const lazy = () => import("./lazy.ts");`, + "lazy.ts": `export default 1;`, + "with-asset.ts": `import asset from "./asset.txt" with { type: "file" };\nconsole.log(asset);`, + "asset.txt": "asset", + "imports.ts": `import value from "virtual-module";\nconsole.log(value);`, + "imports-config.ts": String.raw`import config from "\0virtual:config.ts";` + `\nconsole.log(config);`, + "lazy-plugin.ts": `export const lazy = () => import("from-plugin");`, + "asset-plugin.ts": `import asset from "from-plugin";\nconsole.log(asset);`, + "index.html": ``, + "tsconfig.json": "{}", + "order.txt": "", + // "sub\0tail" cut at the NUL names this directory. + "sub/keep": "", + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + String.raw` + import { readdirSync } from "node:fs"; + const listing = () => readdirSync(".", { recursive: true }).map(name => name.replaceAll("\\", "/")).sort(); + const before = listing(); + const created = () => listing().filter(name => !before.includes(name)); + const loaded = []; + const virtual = { + name: "virtual", + setup(build) { + build.onResolve({ filter: /^virtual-module$/ }, () => ({ path: "\0virtual-module", namespace: "virtual" })); + build.onResolve({ filter: /virtual\.ts$/ }, ({ path }) => ({ + path: path.split(/[\\/]/).pop(), + namespace: "virtual", + })); + build.onLoad({ filter: /.*/, namespace: "virtual" }, ({ path }) => { + loaded.push(path); + return { contents: "export default 3;", loader: "ts" }; + }); + }, + }; + const fromPlugin = (path, loader) => ({ + name: "from-plugin", + setup(build) { + build.onResolve({ filter: /^from-plugin$/ }, () => ({ path, namespace: "from-plugin" })); + build.onLoad({ filter: /.*/, namespace: "from-plugin" }, () => ({ contents: "export default 3;", loader })); + }, + }); + async function logs(builds) { + const logs = {}; + for (const [name, option] of Object.entries(builds)) { + const build = await Bun.build({ entrypoints: ["./entry.ts"], throw: false, ...option }); + logs[name] = build.success + ? "success" + : build.logs.map(log => [log.message, ...log.notes.map(note => note.message)]); + } + return logs; + } + ${script} + `, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + const report = (value: object) => ({ stdout: JSON.stringify(value, null, 2) + "\n", stderr: "", exitCode: 0 }); + const failed = (input: string, output: string, template: string) => [ + [`Output path for "${input}" contains a null byte: "${output}"`, `naming template is "${template}"`], + ]; + + test("a path option throws before the build opens anything", async () => { + const thrown = (option: string, received: string) => + `TypeError ERR_INVALID_ARG_VALUE: The ${option.includes(".") ? "property" : "argument"} '${option}' must be a string without null bytes. Received "${received}"`; + const derived = (what: string, received: string, option: string) => + `TypeError ERR_INVALID_ARG_VALUE: The ${what} "${received}", taken from 'entrypoints', must not contain null bytes. Set '${option}' to override it.`; + expect( + await run(String.raw` + const options = { + "outdir": { outdir: "out\0tail" }, + "outdir from a plugin's setup()": { + plugins: [{ name: "sets-outdir", setup: build => void (build.config.outdir = "out\0tail") }], + }, + "tsconfig": { tsconfig: "tsconfig.json\0tail" }, + "root": { root: "sub\0tail" }, + "root from entrypoints": { entrypoints: ["sub\0tail/entry.ts"] }, + "metafile": { metafile: "meta.json\0tail" }, + "metafile.json": { outdir: "out", metafile: { json: "meta.json\0tail" } }, + "metafile.markdown": { outdir: "out", metafile: { markdown: "meta.md\0tail" } }, + "compile.outfile": { compile: { outfile: "app\0tail" } }, + "compile.outfile from entrypoints": { + entrypoints: ["/de\0rived.ts"], + files: { "/de\0rived.ts": "" }, + compile: true, + }, + "compile.executablePath": { compile: { outfile: "app", executablePath: "entry.ts\0tail" } }, + "compile.windows.icon": { compile: { outfile: "app", windows: { icon: "entry.ts\0tail" } } }, + "compile.bytecodeOrder": { + bytecode: true, + format: "cjs", + compile: { outfile: "app", bytecodeOrder: "order.txt\0tail" }, + }, + "compile.bytecodeOrder[1]": { + bytecode: true, + format: "cjs", + compile: { outfile: "app", bytecodeOrder: ["order.txt", "order.txt\0tail"] }, + }, + }; + const thrown = {}; + for (const [name, option] of Object.entries(options)) { + let build; + try { + build = Bun.build({ entrypoints: ["./entry.ts"], throw: false, ...option }); + } catch (error) { + thrown[name] = error.name + " " + error.code + ": " + error.message; + continue; + } + const result = await build.catch(() => ({ success: "rejected" })); + thrown[name] = "did not throw, success: " + result.success; + } + console.log(JSON.stringify({ thrown, created: created() }, null, 2)); + `), + ).toEqual( + report({ + thrown: { + "outdir": thrown("outdir", "out\\u0000tail"), + "outdir from a plugin's setup()": thrown("outdir", "out\\u0000tail"), + "tsconfig": thrown("tsconfig", "tsconfig.json\\u0000tail"), + "root": thrown("root", "sub\\u0000tail"), + "root from entrypoints": derived("root directory", "sub\\u0000tail", "root"), + "metafile": thrown("metafile", "meta.json\\u0000tail"), + "metafile.json": thrown("metafile.json", "meta.json\\u0000tail"), + "metafile.markdown": thrown("metafile.markdown", "meta.md\\u0000tail"), + "compile.outfile": thrown("compile.outfile", "app\\u0000tail"), + "compile.outfile from entrypoints": derived("executable name", "de\\u0000rived", "compile.outfile"), + "compile.executablePath": thrown("compile.executablePath", "entry.ts\\u0000tail"), + "compile.windows.icon": thrown("compile.windows.icon", "entry.ts\\u0000tail"), + "compile.bytecodeOrder": thrown("compile.bytecodeOrder", "order.txt\\u0000tail"), + "compile.bytecodeOrder[1]": thrown("compile.bytecodeOrder", "order.txt\\u0000tail"), + }, + created: [], + }), + ); + }); + + test("a naming template fails the build before anything is written", async () => { + expect( + await run(String.raw` + const naming = await logs({ + "naming": { outdir: "out", naming: "pre\0post-[name].[ext]" }, + "naming.entry": { outdir: "out", naming: { entry: "pre\0post-[name].[ext]" } }, + "naming.chunk": { + outdir: "out", + entrypoints: ["./split.ts"], + splitting: true, + naming: { chunk: "ch\0unk-[name].[ext]" }, + }, + "naming.asset": { + outdir: "out", + entrypoints: ["./with-asset.ts"], + naming: { asset: "as\0set-[name].[ext]" }, + }, + }); + const rejection = await Bun.build({ entrypoints: ["./entry.ts"], outdir: "out", naming: "pre\0post-[name].[ext]" }) + .then(() => "resolved", error => [error.name, error.message, error.errors.map(item => item.message)]); + console.log(JSON.stringify({ naming, rejection, created: created() }, null, 2)); + `), + ).toEqual( + report({ + naming: { + "naming": failed("entry.ts", "./pre\\u0000post-entry.js", "./pre\\u0000post-[name].[ext]"), + "naming.entry": failed("entry.ts", "./pre\\u0000post-entry.js", "./pre\\u0000post-[name].[ext]"), + "naming.chunk": failed("lazy.ts", "./ch\\u0000unk-lazy.js", "./ch\\u0000unk-[name].[ext]"), + "naming.asset": failed("asset.txt", "./as\\u0000set-asset.txt", "./as\\u0000set-[name].[ext]"), + }, + rejection: [ + "AggregateError", + "Bundle failed", + ['Output path for "entry.ts" contains a null byte: "./pre\\u0000post-entry.js"'], + ], + created: [], + }), + ); + }); + + test("a module path that names an output fails the build before anything is written", async () => { + expect( + await run(String.raw` + const names = await logs({ + "a files key": { + outdir: "out", + entrypoints: [process.cwd() + "/in\0memory.ts"], + files: { [process.cwd() + "/in\0memory.ts"]: "" }, + }, + "an entry point that a plugin resolves": { + outdir: "out", + entrypoints: ["a\0b-virtual.ts"], + plugins: [virtual], + }, + // Cut at the NUL, this [dir] is the directory "sub". + "[dir]": { outdir: "out", root: ".", entrypoints: ["sub\0tail/virtual.ts"], plugins: [virtual] }, + "a chunk that a plugin path names": { + outdir: "out", + entrypoints: ["./lazy-plugin.ts"], + splitting: true, + naming: { chunk: "[name].[ext]" }, + plugins: [fromPlugin("ch\0unk.ts", "ts")], + }, + "an asset that a plugin path names": { + outdir: "out", + entrypoints: ["./asset-plugin.ts"], + naming: { asset: "[name].[ext]" }, + plugins: [fromPlugin("pic\0ture.png", "file")], + }, + "an asset extension from a plugin path": { + outdir: "out", + entrypoints: ["./asset-plugin.ts"], + naming: { asset: "[name].[ext]" }, + plugins: [fromPlugin("picture.p\0ng", "file")], + }, + }); + console.log(JSON.stringify({ names, created: created() }, null, 2)); + `), + ).toEqual( + report({ + names: { + "a files key": failed("in\\u0000memory.ts", "./in\\u0000memory.js", "[dir]/[name].[ext]"), + "an entry point that a plugin resolves": failed( + "virtual:a\\u0000b-virtual.ts", + "./a\\u0000b-virtual.js", + "[dir]/[name].[ext]", + ), + "[dir]": failed("virtual:virtual.ts", "sub\\u0000tail/virtual.js", "[dir]/[name].[ext]"), + "a chunk that a plugin path names": failed( + "from-plugin:ch\\u0000unk.ts", + "./ch\\u0000unk.js", + "./[name].[ext]", + ), + "an asset that a plugin path names": failed( + "from-plugin:pic\\u0000ture.png", + "./pic\\u0000ture.png", + "./[name].[ext]", + ), + "an asset extension from a plugin path": failed( + "from-plugin:picture.p\\u0000ng", + "./picture.p\\u0000ng", + "./[name].[ext]", + ), + }, + created: [], + }), + ); + }); + + test("a standalone HTML build and an executable fail the build for a name that they open", async () => { + expect( + await run(String.raw` + const opened = await logs({ + "the HTML of a standalone HTML build": { + outdir: "out", + entrypoints: ["./index.html"], + compile: true, + target: "browser", + naming: { entry: "en\0try-[name].[ext]" }, + }, + "a .map of a standalone HTML build": { + outdir: "out", + entrypoints: ["./index.html"], + compile: true, + target: "browser", + sourcemap: "linked", + naming: { chunk: "ch\0unk-[name].[ext]" }, + }, + "a .map of an executable": { + entrypoints: ["./split.ts"], + splitting: true, + sourcemap: "external", + naming: { chunk: "ch\0unk-[name].[ext]" }, + compile: { outfile: "app" }, + }, + }); + console.log(JSON.stringify({ opened, created: created() }, null, 2)); + `), + ).toEqual( + report({ + opened: { + "the HTML of a standalone HTML build": failed( + "index.html", + "./en\\u0000try-index.html", + "./en\\u0000try-[name].[ext]", + ), + "a .map of a standalone HTML build": failed( + "index.html", + "./ch\\u0000unk-index.js", + "./ch\\u0000unk-[name].[ext]", + ), + "a .map of an executable": failed("lazy.ts", "./ch\\u0000unk-lazy.js", "./ch\\u0000unk-[name].[ext]"), + }, + created: [], + }), + ); + }); + + test("a name that the build does not open still builds", async () => { + expect( + await run(String.raw` + const builds = { + "naming without an outdir": { naming: "pre\0post-[name].[ext]" }, + "[dir] without an outdir": { root: ".", entrypoints: ["sub\0tail/virtual.ts"], plugins: [virtual] }, + "an entry point that does not exist": { outdir: "out1", entrypoints: ["./entry.ts\0tail"] }, + "a plugin path inside a chunk": { outdir: "out2", entrypoints: ["./imports.ts"], plugins: [virtual] }, + "a files key inside a chunk": { + outdir: "out3", + entrypoints: ["./imports-config.ts"], + files: { "\0virtual:config.ts": "export default 1;" }, + }, + "publicPath and banner": { outdir: "out4", publicPath: "/p\0q/", banner: "// a\0b" }, + "the inlined chunk and asset of a standalone HTML build": { + outdir: "out5", + entrypoints: ["./index.html"], + compile: true, + target: "browser", + naming: { chunk: "ch\0unk-[name].[ext]", asset: "as\0set-[name].[ext]" }, + }, + }; + const results = {}; + for (const [name, option] of Object.entries(builds)) { + const build = await Bun.build({ entrypoints: ["./entry.ts"], throw: false, ...option }); + results[name] = build.success + ? build.outputs.map(output => output.path.replace(process.cwd(), "").replaceAll("\\", "/")) + : build.logs.map(log => log.message); + } + console.log(JSON.stringify({ results, loaded, created: created() }, null, 2)); + `), + ).toEqual( + report({ + results: { + "naming without an outdir": ["./pre\0post-entry.js"], + "[dir] without an outdir": ["sub\0tail/virtual.js"], + "an entry point that does not exist": ['ModuleNotFound resolving "./entry.ts\0tail" (entry point)'], + "a plugin path inside a chunk": ["/out2/imports.js"], + "a files key inside a chunk": ["/out3/imports-config.js"], + "publicPath and banner": ["/out4/entry.js"], + "the inlined chunk and asset of a standalone HTML build": ["/out5/index.html"], + }, + loaded: ["virtual.ts", "\0virtual-module"], + created: [ + "out2", + "out2/imports.js", + "out3", + "out3/imports-config.js", + "out4", + "out4/entry.js", + "out5", + "out5/index.html", + ], + }), + ); + }); + + // An executable embeds its chunks and assets, and writes the `.map` of a chunk under its basename. The asset that + // does not exist stops each build after the name check, before it copies the running executable. + test("an executable does not check a name that it embeds, or the directory of a .map", async () => { + expect( + await run(String.raw` + const builds = { + "an embedded chunk and asset": { + entrypoints: ["./split.ts", "./with-asset.ts"], + naming: { chunk: "ch\0unk-[name].[ext]", asset: "as\0set-[name].[ext]" }, + }, + "the directory of a .map": { + entrypoints: ["./split.ts"], + sourcemap: "external", + naming: { chunk: "d\0ir/[name].[ext]" }, + }, + }; + const stopped = {}; + for (const [name, option] of Object.entries(builds)) { + const build = await Bun.build({ + splitting: true, + compile: { outfile: "app", assets: ["missing.bin"] }, + throw: false, + ...option, + }); + stopped[name] = build.success ? "success" : build.logs.map(log => log.message.replace(/: .*/s, "")); + } + console.log(JSON.stringify({ stopped, created: created() }, null, 2)); + `), + ).toEqual( + report({ + stopped: { + "an embedded chunk and asset": ['failed to read asset "missing.bin"'], + "the directory of a .map": ['failed to read asset "missing.bin"'], + }, + created: [], + }), + ); + }); +}); diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index 60600531627a..e9cc9f6a0a0f 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -946,6 +946,29 @@ test("reports an empty entry point the same way with more than 8 entry points", expect(nine).toEqual(two); }); +// A string in bunfig.toml can have a NUL byte, which no argument can. A syscall reads a path only up to its first NUL +// byte, so the root directory that `bun build` takes from this entry point was "sub", and a debug build aborted. +test.concurrent("an entry point from bunfig.toml with a NUL byte in its directory is an error", async () => { + using dir = tempDir("build-bunfig-nul-entry", { + "bunfig.toml": String.raw`[bundle]` + "\n" + String.raw`entryPoints = ["sub\u0000tail/entry.ts"]` + "\n", + "sub/entry.ts": `console.log("entry");`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--outdir=out"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode, out: fs.existsSync(join(String(dir), "out")) }).toEqual({ + stdout: "", + stderr: 'error: root directory "sub\\u0000tail" contains a null byte\n', + exitCode: 1, + out: false, + }); +}); + describe("CLI argument error messages", () => { test("--format with an unrecognized value echoes the value back", async () => { using dir = tempDir("build-format-err", { "in.js": "console.log(1)" });