From 0dc453fc2eb00bb8fb38c02e7fe89738a408993f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:35:03 +0000 Subject: [PATCH 1/3] bundler: JS-escape file-loader asset paths at chunk assembly The JS printer emits file-loader asset paths as a unique-key placeholder inside a double-quoted string literal, and chunk assembly later replaces the placeholder with the final path via raw byte splice. A filename (or --public-path) containing '"', '\\', LF, CR, or U+2028/U+2029 therefore terminated the string literal and became executable source: a crafted asset filename ran arbitrary JS in the bundled output and inside 'bun build --compile' binaries, and a bare newline produced a binary that compiled cleanly (exit 0) but died with SyntaxError at startup. code_with_source_map_shifts now JS-string-escapes the substituted path when the containing chunk is JavaScript. Count and write passes stay exact; CSS/HTML chunk substitution is unchanged. --- src/bundler/Chunk.rs | 95 +++++++++++++++--- test/bundler/bundler_loader.test.ts | 143 +++++++++++++++++++++++++++- 2 files changed, 224 insertions(+), 14 deletions(-) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 2010966be9d9..be8f6931072f 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -566,6 +566,69 @@ impl IntermediateOutput { dst } + /// Extra bytes needed to render `path` inside a double-quoted JS string + /// literal. The printer emits every unique-key placeholder inside `"..."`, + /// so only the characters that terminate or corrupt such a literal need + /// escaping here: `"`, `\`, LF, CR, and U+2028/U+2029. + fn js_string_extra_escape_bytes(path: &[u8]) -> usize { + let mut extra: usize = 0; + let mut i: usize = 0; + while i < path.len() { + match path[i] { + b'"' | b'\\' | b'\n' | b'\r' => extra += 1, + 0xE2 if i + 2 < path.len() && path[i + 1] == 0x80 && (path[i + 2] & !1) == 0xA8 => { + extra += "\\u2028".len() - 3; + i += 2; + } + _ => {} + } + i += 1; + } + extra + } + + /// Copy `path` into `dest`, escaping the bytes counted by + /// `js_string_extra_escape_bytes`. Returns bytes written. + fn memcpy_js_string_escaped(dest: &mut [u8], path: &[u8]) -> usize { + let mut dst: usize = 0; + let mut i: usize = 0; + while i < path.len() { + let b = path[i]; + match b { + b'"' | b'\\' => { + dest[dst] = b'\\'; + dest[dst + 1] = b; + dst += 2; + } + b'\n' => { + dest[dst] = b'\\'; + dest[dst + 1] = b'n'; + dst += 2; + } + b'\r' => { + dest[dst] = b'\\'; + dest[dst + 1] = b'r'; + dst += 2; + } + 0xE2 if i + 2 < path.len() && path[i + 1] == 0x80 && (path[i + 2] & !1) == 0xA8 => { + dest[dst..][..6].copy_from_slice(if path[i + 2] == 0xA8 { + b"\\u2028" + } else { + b"\\u2029" + }); + dst += 6; + i += 2; + } + _ => { + dest[dst] = b; + dst += 1; + } + } + i += 1; + } + dst + } + pub(crate) fn get_size(&self) -> usize { match self { IntermediateOutput::Pieces(pieces) => { @@ -696,6 +759,10 @@ impl IntermediateOutput { graph.input_files.items_unique_key_for_additional_file(); let mut relative_platform_buf = bun_paths::path_buffer_pool::get(); let mut file_path_buf = bun_paths::path_buffer_pool::get(); + // In JS chunks every placeholder lands inside a printer-emitted `"..."` + // literal; the substituted path must be JS-string-escaped so filename + // bytes cannot terminate the literal. + let escape_for_js = chunk.content.is_javascript(); match self { IntermediateOutput::Pieces(pieces) => { let entry_point_chunks_for_scb = linker_graph.files.items_entry_point_chunk_index(); @@ -822,6 +889,10 @@ impl IntermediateOutput { }, ); count += cheap_normalizer[0].len() + cheap_normalizer[1].len(); + if escape_for_js { + count += Self::js_string_extra_escape_bytes(cheap_normalizer[0]) + + Self::js_string_extra_escape_bytes(cheap_normalizer[1]); + } } QueryKind::None => {} } @@ -1006,22 +1077,20 @@ impl IntermediateOutput { }, ); - if !cheap_normalizer[0].is_empty() { - remain[..cheap_normalizer[0].len()] - .copy_from_slice(cheap_normalizer[0]); - remain = &mut remain[cheap_normalizer[0].len()..]; - if ENABLE_SOURCE_MAP_SHIFTS { - shift.after.advance(cheap_normalizer[0]); + for part in cheap_normalizer { + if part.is_empty() { + continue; } - } - - if !cheap_normalizer[1].is_empty() { - remain[..cheap_normalizer[1].len()] - .copy_from_slice(cheap_normalizer[1]); - remain = &mut remain[cheap_normalizer[1].len()..]; + let written = if escape_for_js { + Self::memcpy_js_string_escaped(remain, part) + } else { + remain[..part.len()].copy_from_slice(part); + part.len() + }; if ENABLE_SOURCE_MAP_SHIFTS { - shift.after.advance(cheap_normalizer[1]); + shift.after.advance(&remain[..written]); } + remain = &mut remain[written..]; } if ENABLE_SOURCE_MAP_SHIFTS { diff --git a/test/bundler/bundler_loader.test.ts b/test/bundler/bundler_loader.test.ts index e7a7d02051d5..9b31b4009a31 100644 --- a/test/bundler/bundler_loader.test.ts +++ b/test/bundler/bundler_loader.test.ts @@ -1,7 +1,8 @@ import { fileURLToPath, Loader } from "bun"; -import { describe, expect } from "bun:test"; +import { describe, expect, test } from "bun:test"; import fs, { readdirSync } from "node:fs"; import { join } from "path"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { itBundled } from "./expectBundled"; describe("bundler", async () => { @@ -521,6 +522,146 @@ describe("bundler", async () => { } }); + // Windows cannot represent ", \, \n in filenames. + describe.skipIf(isWindows)("file loader escapes asset path in JS output", () => { + const assetContent = "asset-bytes"; + const cases: Array<[label: string, name: string]> = [ + ["double quote injection", 'x";process.exit(42);"y.txt'], + ["newline", "nl\nname.txt"], + ["carriage return", "cr\rname.txt"], + ["line separator U+2028", "ls\u2028name.txt"], + ]; + + for (const [label, name] of cases) { + test.concurrent(`bundle: ${label}`, async () => { + using dir = tempDir("file-loader-escape", { + "entry.ts": + `import p from ${JSON.stringify("./" + name)} with { type: "file" };\n` + + `import path from "node:path";\n` + + `import fs from "node:fs";\n` + + `const abs = path.resolve(import.meta.dir, p);\n` + + `console.log(JSON.stringify({ path: p, content: fs.readFileSync(abs, "utf8") }));\n`, + }); + fs.writeFileSync(join(String(dir), name), assetContent); + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--target=bun", "./entry.ts", "--outdir=./out"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + await using proc = Bun.spawn({ + cmd: [bunExe(), "./out/entry.js"], + 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: stdout.trim(), exitCode }).toEqual({ + stdout: expect.stringContaining(`"content":"${assetContent}"`), + exitCode: 0, + }); + expect(stderr).not.toContain("SyntaxError"); + const emitted = JSON.parse(stdout).path as string; + expect(fs.existsSync(join(String(dir), "out", emitted))).toBe(true); + }); + } + + for (const [label, name] of [ + ["double quote injection", 'x";process.exit(42);"y.txt'], + ["newline in filename", "nl\nname.txt"], + ] as const) { + test(`compile: ${label}`, async () => { + using dir = tempDir("file-loader-escape-compile", { + "entry.ts": + `import p from ${JSON.stringify("./" + name)} with { type: "file" };\n` + + `console.log(JSON.stringify({ path: p, content: await Bun.file(p).text() }));\n`, + }); + fs.writeFileSync(join(String(dir), name), assetContent); + const outfile = join(String(dir), "exe"); + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", "./entry.ts", "--outfile", outfile], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + await using proc = Bun.spawn({ + cmd: [outfile], + 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: stdout.trim(), exitCode }).toEqual({ + stdout: expect.stringContaining(`"content":"${assetContent}"`), + exitCode: 0, + }); + expect(stderr).not.toContain("SyntaxError"); + }); + } + + test.concurrent("bundle: public-path with double quote and backslash", async () => { + using dir = tempDir("file-loader-escape-public-path", { + "entry.ts": + `import p from "./asset.txt" with { type: "file" };\n` + + `console.log(JSON.stringify({ path: p }));\n`, + "asset.txt": assetContent, + }); + + { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--target=bun", + '--public-path=";process.exit(42);\\"/', + "./entry.ts", + "--outdir=./out", + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + await using proc = Bun.spawn({ + cmd: [bunExe(), "./out/entry.js"], + 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: stdout.trim(), exitCode }).toEqual({ + stdout: expect.stringContaining('{"path":'), + exitCode: 0, + }); + expect(stderr).not.toContain("SyntaxError"); + expect(JSON.parse(stdout).path).toStartWith('";process.exit(42);\\"/asset-'); + }); + }); + // Lazy-export modules (JSON, TOML, CSS modules, ...) used to crash the // printer when bundled with the dev server's module format. // https://github.com/oven-sh/bun/issues/31943 From 782f9cd0752a1acb3227673d71d53c67d66f6e88 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:37:28 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- test/bundler/bundler_loader.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/bundler/bundler_loader.test.ts b/test/bundler/bundler_loader.test.ts index 9b31b4009a31..1ae244cd53c7 100644 --- a/test/bundler/bundler_loader.test.ts +++ b/test/bundler/bundler_loader.test.ts @@ -1,8 +1,8 @@ import { fileURLToPath, Loader } from "bun"; import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import fs, { readdirSync } from "node:fs"; import { join } from "path"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { itBundled } from "./expectBundled"; describe("bundler", async () => { @@ -620,8 +620,7 @@ describe("bundler", async () => { test.concurrent("bundle: public-path with double quote and backslash", async () => { using dir = tempDir("file-loader-escape-public-path", { "entry.ts": - `import p from "./asset.txt" with { type: "file" };\n` + - `console.log(JSON.stringify({ path: p }));\n`, + `import p from "./asset.txt" with { type: "file" };\n` + `console.log(JSON.stringify({ path: p }));\n`, "asset.txt": assetContent, }); From c0a5a1ac0419ae36d201ae42a5eaf847346ca62c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:53:39 +0000 Subject: [PATCH 3/3] Normalize file_path in the count pass to match the write pass The write pass applies platform_to_posix_in_place (backslash to forward slash on Windows) before computing escape bytes, but the count pass used the raw path. When dest_path contains a Windows separator (e.g. with --asset-naming='assets/[name]-[hash].[ext]') the count pass would over-count by one escape byte per backslash, leaving trailing zero bytes in the output buffer (debug-assert panic / corrupt JS in release). Both passes now see the same posix-normalized bytes. Adds a cross-platform itBundled case with a subdir asset-naming template so Windows CI covers the substituted-path code path. --- src/bundler/Chunk.rs | 9 +++++++++ test/bundler/bundler_loader.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index be8f6931072f..61684ccca9bd 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -875,6 +875,15 @@ impl IntermediateOutput { QueryKind::None => unreachable!(), }; + // Same `\` → `/` normalization as the write pass so the + // escape-byte count below matches what will be emitted. + let file_path: &[u8] = { + let n = file_path.len(); + let dst = &mut file_path_buf[..n]; + dst.copy_from_slice(file_path); + bun_paths::resolve_path::platform_to_posix_in_place::(dst); + dst + }; let cheap_normalizer = cheap_prefix_normalizer( import_prefix, if use_outdir_relative_path { diff --git a/test/bundler/bundler_loader.test.ts b/test/bundler/bundler_loader.test.ts index 1ae244cd53c7..0f4e24234545 100644 --- a/test/bundler/bundler_loader.test.ts +++ b/test/bundler/bundler_loader.test.ts @@ -661,6 +661,32 @@ describe("bundler", async () => { }); }); + // Count pass and write pass must agree on the substituted bytes; on Windows + // the write pass posix-normalizes `\` -> `/` before escaping, so the count + // pass must too. A subdir in --asset-naming is enough to put a separator in + // dest_path; the output must be parseable and contain no trailing NUL bytes. + itBundled("bun/loader-file-asset-naming-subdir", { + target: "bun", + outdir: "/out", + assetNaming: "assets/[name]-[hash].[ext]", + files: { + "/entry.ts": /* js */ ` + import p from "./data.txt" with { type: "file" }; + console.log(JSON.stringify({ path: p })); + `, + "/data.txt": "asset-bytes", + }, + run: { + validate({ stdout }) { + expect(JSON.parse(stdout).path).toMatch(/^\.\/assets\/data-[a-z0-9]+\.txt$/); + }, + }, + onAfterBundle(api) { + const out = api.readFile("out/entry.js"); + expect(out).not.toContain("\0"); + }, + }); + // Lazy-export modules (JSON, TOML, CSS modules, ...) used to crash the // printer when bundled with the dev server's module format. // https://github.com/oven-sh/bun/issues/31943