From 06b644b2db07f131858e764a03367abd037ff79a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:08:17 +0000 Subject: [PATCH 1/6] bundler: resolve relative Bun.build files keys against the cwd The files map stored each key verbatim, so a relative key never matched anything the bundler looked up by absolute path: the documented "./src/config.ts" override and "./src/generated.ts" virtual file were ignored, and a relative key used as an entry point produced a file namespace Path with relative text, which trips the is_absolute assertions in enqueue_entry_item and the resolver on debug builds. FileMap now has one canonical spelling for keys (forward slashes, relative paths resolved against the cwd like relative entry points) that put() and every lookup go through, so keys are compared with entry points, import targets and source paths in one spelling only. The relative import join uses the checked variant, so an over-long import specifier in a build with files set is a resolve error instead of a buffer overflow panic, and a key whose resolved path does not fit in a path buffer is rejected when the options are parsed. --- docs/bundler/index.mdx | 2 +- packages/bun-types/bun.d.ts | 6 +- src/bundler/bundle_v2.rs | 268 +++++++++++++++-------------- src/runtime/api/JSBundler.rs | 21 +-- test/bundler/bundler_files.test.ts | 211 ++++++++++++++++++++++- 5 files changed, 364 insertions(+), 144 deletions(-) diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index d59d6e77e973..f8f65087b646 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -222,7 +222,7 @@ An array of paths corresponding to the entrypoints of your application. Bun gene A map of file paths to their contents for in-memory bundling: bundle virtual files that don't exist on disk, or override the contents of files that do. This option is only available in the JavaScript API. -File contents can be provided as a `string`, `Blob`, `TypedArray`, or `ArrayBuffer`. +File contents can be provided as a `string`, `Blob`, `TypedArray`, or `ArrayBuffer`. Keys are file paths: a relative key is resolved against the current working directory, like a relative entrypoint, and an import uses the in-memory file when it resolves to that path. #### Bundle entirely from memory diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 175f4b6f4b4e..af597580bec9 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3094,8 +3094,10 @@ declare module "bun" { * A map of file paths to their contents for in-memory bundling. * * Use this to bundle virtual files that don't exist on disk, or override - * the contents of files that do exist on disk. The keys are file paths (which should - * match how they're imported) and the values are the file contents. + * the contents of files that do exist on disk. The keys are file paths and + * the values are the file contents. A relative key is resolved against the + * current working directory, like a relative entrypoint, and an import + * uses the in-memory file when it resolves to that path. * * File contents can be provided as: * - `string` - The source code as a string diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index f9be9d28d01a..e247461c30a8 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -873,49 +873,91 @@ pub mod bv2_impl { /// The bundler only ever reads `.slice()`, so the moved-down map /// stores raw bytes. /// `bun_runtime`'s `from_js` parses JS values via `BlobOrStringOrBuffer` - /// in async (owning-copy) mode and inserts the extracted bytes here. + /// in async (owning-copy) mode and inserts the extracted bytes via + /// [`FileMap::put`]. + /// + /// Keys are stored in the form [`FileMap::canonical`] produces, and + /// every lookup canonicalizes its input the same way, so a key and a + /// path can only ever be compared in one spelling. #[derive(Default)] pub struct FileMap { pub map: bun_collections::StringHashMap>, } impl FileMap { - pub(crate) fn get(&self, specifier: &[u8]) -> Option<&[u8]> { + /// Fails with [`bun_paths::Error::MaxPathExceeded`] when the + /// resolved `path` does not fit in a path buffer. + pub fn put(&mut self, path: &[u8], contents: Box<[u8]>) -> bun_paths::Result<()> { + let mut buf = bun_paths::path_buffer_pool::get(); + let key = Self::canonical(path, &mut **buf) + .ok_or(bun_paths::Error::MaxPathExceeded)?; + self.map.put_assume_capacity(key, contents); + Ok(()) + } + + /// The one spelling of a path that keys are stored under: `/` + /// separators (plus an uppercase drive letter on Windows), and a + /// non-absolute path resolved against the cwd, which is also what + /// a non-absolute entry point is resolved against. An absolute + /// path keeps its text, so it is also the `Path.text` an + /// in-memory source ends up with. `None` if the result does not + /// fit in `buf`. + fn canonical<'b>(path: &[u8], buf: &'b mut [u8]) -> Option<&'b [u8]> { + let mut scratch = bun_paths::path_buffer_pool::get(); + let posix = scratch.get_mut(..path.len())?; + posix.copy_from_slice(path); + bun_paths::resolve_path::dangerously_convert_path_to_posix_in_place::( + posix, + ); + if bun_paths::is_absolute(posix) { + let out = buf.get_mut(..posix.len())?; + out.copy_from_slice(posix); + return Some(out); + } + let len = bun_paths::resolve_path::join_abs_string_buf_checked::< + bun_paths::platform::Auto, + >( + bun_resolver::fs::FileSystem::get().top_level_dir, + buf, + &[posix], + )? + .len(); + let out = &mut buf[..len]; + bun_paths::resolve_path::dangerously_convert_path_to_posix_in_place::(out); + Some(out) + } + + /// Map-owned key and contents for an already canonical path. + fn entry(&self, canonical: &[u8]) -> Option<(&[u8], &[u8])> { + self.map + .get_key_value(canonical) + .map(|(key, contents)| (key.as_ref(), contents.as_ref())) + } + + /// `path` is an entry point or a source path, i.e. a path that + /// stands on its own (relative to the cwd), not an import + /// specifier. + fn lookup(&self, path: &[u8]) -> Option<(&[u8], &[u8])> { if self.map.is_empty() { return None; } - #[cfg(not(windows))] - { - self.map.get(specifier).map(|b| b.as_ref()) - } - #[cfg(windows)] - { - let mut buf = bun_paths::path_buffer_pool::get(); - let normalized = - bun_paths::resolve_path::path_to_posix_buf(specifier, &mut **buf); - self.map.get(normalized).map(|b| b.as_ref()) - } + let mut buf = bun_paths::path_buffer_pool::get(); + self.entry(Self::canonical(path, &mut **buf)?) + } + + pub(crate) fn get(&self, path: &[u8]) -> Option<&[u8]> { + self.lookup(path).map(|(_, contents)| contents) } + #[inline] - pub fn contains(&self, specifier: &[u8]) -> bool { - if self.map.is_empty() { - return false; - } - #[cfg(not(windows))] - { - self.map.contains_key(specifier) - } - #[cfg(windows)] - { - let mut buf = bun_paths::path_buffer_pool::get(); - let normalized = - bun_paths::resolve_path::path_to_posix_buf(specifier, &mut **buf); - self.map.contains_key(normalized) - } + pub fn contains(&self, path: &[u8]) -> bool { + self.lookup(path).is_some() } + /// Returns a `resolver::Result` for a file in the map, or `None` if - /// not found. Handles direct key matches and relative specifiers - /// joined against `dirname(source_file)` (with Windows - /// drive-letter / separator normalization). + /// not found. `specifier` is looked up as a path of its own when it + /// is absolute or an entry point (`source_file` is empty), and + /// otherwise joined against `dirname(source_file)` like the + /// resolver would. /// /// `arena` is the build's bump arena (`BundleV2::arena()`); /// the matched key is copied into it so the returned @@ -928,117 +970,87 @@ pub mod bv2_impl { source_file: &[u8], specifier: &[u8], ) -> Option { - if self.map.is_empty() { + if self.map.is_empty() || specifier.is_empty() { return None; } + let (key, _) = + if source_file.is_empty() || bun_paths::is_absolute_loose(specifier) { + self.lookup(specifier)? + } else { + self.lookup_import(source_file, specifier)? + }; + // SAFETY: ARENA — `arena` is the build-pass bump arena // (never freed before the `Result` is consumed); detaching the // borrow lifetime matches the established `Path<'static>` // convention used throughout `bun_resolver` (PORTING.md // §Lifetimes: ARENA → `&'bump T`). - let dupe = |key: &[u8]| -> &'static [u8] { - // SAFETY: see ARENA note above — bytes live in the build-pass arena. - unsafe { bun_ptr::detach_lifetime(arena.alloc_slice_copy(key)) } - }; - - // Direct key match (must use `getKey` to return the map-owned - // key, not the parameter). - #[cfg(not(windows))] - if let Some((key, _)) = self.map.get_key_value(specifier) { - return Some(Self::result_for_key(dupe(key.as_ref()))); - } - #[cfg(windows)] - { - let mut buf = bun_paths::path_buffer_pool::get(); - let normalized = - bun_paths::resolve_path::path_to_posix_buf(specifier, &mut **buf); - if let Some((key, _)) = self.map.get_key_value(normalized) { - return Some(Self::result_for_key(dupe(key.as_ref()))); - } - } - - // Also try joining a relative specifier against the importer's - // directory. Relative = not posix-absolute and not Windows - // drive-absolute (e.g. `C:/`). - if !specifier.is_empty() && !bun_paths::is_absolute_loose(specifier) { - // `source_file` may itself be relative (e.g. on Windows - // when the bundler stores paths relative to cwd). - let mut abs_source_buf = bun_paths::path_buffer_pool::get(); - let abs_source_file: &[u8] = if bun_paths::is_absolute_loose(source_file) { - source_file - } else { - bun_resolver::fs::FileSystem::instance() - .abs_buf(&[source_file], &mut *abs_source_buf) - }; - - // Normalize `source_file` to forward slashes (Windows paths - // from the real filesystem may use backslashes). - let mut source_file_buf = bun_paths::path_buffer_pool::get(); - let normalized_source_file = bun_paths::resolve_path::path_to_posix_buf::( - abs_source_file, - &mut **source_file_buf, - ); - - let mut buf = bun_paths::path_buffer_pool::get(); - let source_dir = bun_paths::resolve_path::dirname::< - bun_paths::platform::Posix, - >(normalized_source_file); - // If `dirname` returns empty but the path has a drive - // letter, use the drive root. - let effective_source_dir: &[u8] = if source_dir.is_empty() { - if normalized_source_file.len() >= 3 - && normalized_source_file[1] == b':' - && normalized_source_file[2] == b'/' - { - &normalized_source_file[0..3] // "C:/" - } else if !normalized_source_file.is_empty() - && normalized_source_file[0] == b'/' - { - b"/" - } else { - bun_resolver::fs::FileSystem::instance().top_level_dir - } - } else { - source_dir - }; - // `.loose` preserves Windows drive letters; normalize - // separators in-place on Windows afterwards. - let joined_len = bun_paths::resolve_path::join_abs_string_buf::< - bun_paths::platform::Loose, - >( - effective_source_dir, &mut **buf, &[specifier] - ) - .len(); - if cfg!(windows) { - bun_paths::resolve_path::platform_to_posix_in_place::( - &mut buf[0..joined_len], - ); - } - let joined = &buf[0..joined_len]; - if let Some((key, _)) = self.map.get_key_value(joined) { - return Some(Self::result_for_key(dupe(key.as_ref()))); - } - } - - None - } - - /// Build a `bun_resolver::Result` for a matched key. `key` must - /// already satisfy `'static` — see [`resolve`], which copies the - /// map-owned key into the build's bump arena before calling here so - /// the resulting `Path<'static>` borrows arena memory rather than - /// forging a `'static` from a map borrow. - #[inline] - fn result_for_key(key: &'static [u8]) -> bun_resolver::Result { - bun_resolver::Result { + let key: &'static [u8] = + unsafe { bun_ptr::detach_lifetime(arena.alloc_slice_copy(key)) }; + Some(bun_resolver::Result { path_pair: bun_resolver::PathPair { primary: crate::bun_fs::Path::init_with_namespace(key, b"file"), ..Default::default() }, module_type: crate::options::ModuleType::Unknown, ..Default::default() - } + }) + } + + /// A relative (or bare) `specifier` imported by `source_file`. + fn lookup_import( + &self, + source_file: &[u8], + specifier: &[u8], + ) -> Option<(&[u8], &[u8])> { + // `source_file` may itself be relative (e.g. on Windows + // when the bundler stores paths relative to cwd). + let mut abs_source_buf = bun_paths::path_buffer_pool::get(); + let abs_source_file: &[u8] = if bun_paths::is_absolute_loose(source_file) { + source_file + } else { + bun_resolver::fs::FileSystem::get() + .abs_buf(&[source_file], &mut *abs_source_buf) + }; + + // Normalize `source_file` to forward slashes (Windows paths + // from the real filesystem may use backslashes). + let mut source_file_buf = bun_paths::path_buffer_pool::get(); + let normalized_source_file = bun_paths::resolve_path::path_to_posix_buf::( + abs_source_file, + &mut **source_file_buf, + ); + + let source_dir = bun_paths::resolve_path::dirname::( + normalized_source_file, + ); + // If `dirname` returns empty but the path has a drive + // letter, use the drive root. + let effective_source_dir: &[u8] = if source_dir.is_empty() { + if normalized_source_file.len() >= 3 + && normalized_source_file[1] == b':' + && normalized_source_file[2] == b'/' + { + &normalized_source_file[0..3] // "C:/" + } else if !normalized_source_file.is_empty() + && normalized_source_file[0] == b'/' + { + b"/" + } else { + bun_resolver::fs::FileSystem::get().top_level_dir + } + } else { + source_dir + }; + // `.loose` preserves Windows drive letters. A specifier too + // long to join is not in the map; the resolver reports it. + let mut buf = bun_paths::path_buffer_pool::get(); + let joined = + bun_paths::resolve_path::join_abs_string_buf_checked::< + bun_paths::platform::Loose, + >(effective_source_dir, &mut **buf, &[specifier])?; + self.lookup(joined) } } diff --git a/src/runtime/api/JSBundler.rs b/src/runtime/api/JSBundler.rs index 53326b259f43..2ba21b882f3c 100644 --- a/src/runtime/api/JSBundler.rs +++ b/src/runtime/api/JSBundler.rs @@ -43,7 +43,7 @@ pub mod js_bundler { /// A map of file paths to their in-memory contents. /// LAYERING: the data-only struct (`map: StringHashMap>`) and - /// `get`/`contains`/`resolve` live in `bun_bundler::bundle_v2` so the + /// `put`/`get`/`contains`/`resolve` live in `bun_bundler::bundle_v2` so the /// bundler thread can read it without depending on `bun_runtime`. Only /// the JS-aware `from_js` constructor lives here. pub use bun_bundler::bundle_v2::api::JSBundler::FileMap; @@ -96,17 +96,14 @@ pub mod js_bundler { let bytes: Box<[u8]> = blob_or_string.slice().to_vec().into_boxed_slice(); drop(blob_or_string); - // Clone the key since we need to own it. - let mut key = prop.to_owned_slice(); - - // Normalize backslashes to forward slashes for cross-platform consistency. - // Use dangerouslyConvertPathToPosixInPlace which always converts \ to / - // (uses sep_windows constant, not sep which varies by target). - bun_paths::resolve_path::dangerously_convert_path_to_posix_in_place::( - key.as_mut_slice(), - ); - - this.map.put_assume_capacity(&key, bytes); + let key = prop.to_utf8(); + if this.put(key.slice(), bytes).is_err() { + return Err(global_this.throw_invalid_arguments(format_args!( + "files: key resolves to a path longer than {} bytes", + bun_paths::MAX_PATH_BYTES + ))); + } + drop(key); } Ok(this) diff --git a/test/bundler/bundler_files.test.ts b/test/bundler/bundler_files.test.ts index 81a5d904576b..197dadc11371 100644 --- a/test/bundler/bundler_files.test.ts +++ b/test/bundler/bundler_files.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; +import { bunRun, isWindows, tempDir } from "harness"; describe("bundler files option", () => { test("basic in-memory file bundling", async () => { @@ -582,4 +582,213 @@ describe("bundler files option", () => { const output = await result.outputs[0].text(); expect(output).toContain("injected by plugin"); }); + + // Relative keys are resolved against the cwd, like relative entrypoints. + // Bun.build reads the process cwd, so these run a fixture inside its own + // directory instead of chdir-ing the test runner. + describe("relative keys", () => { + // Appended to a fixture that defines `options`: prints the build result + // and which of the string literals in `markers` ended up in the bundle. + function report(markers: string[]) { + return ` + const result = await Bun.build({ ...options, throw: false }); + const output = result.success ? await result.outputs[0].text() : ""; + console.log(JSON.stringify({ + success: result.success, + logs: result.logs.map(String), + found: ${JSON.stringify(markers)}.filter(marker => output.includes(JSON.stringify(marker))), + })); + `; + } + + // The two examples from the `files` docs: override a file that exists on + // disk, and provide one that does not, both keyed relative to the cwd + // while the importer refers to them as "./config.ts" / "./generated.ts". + test.concurrent.each([ + ["./src/config.ts", "./src/generated.ts"], + ["src/config.ts", "src/generated.ts"], + ["./src/../src/config.ts", "./src/./generated.ts"], + [".\\src\\config.ts", ".\\src\\generated.ts"], + ])("disk entrypoint picks up %j and %j", async (configKey, generatedKey) => { + using dir = tempDir("bundler-files-relative-override", { + "src/index.ts": ` + import { config } from "./config.ts"; + import { generated } from "./generated.ts"; + console.log(config, generated); + `, + "src/config.ts": `export const config = "config from disk";`, + "build.ts": ` + const options = { + entrypoints: ["./src/index.ts"], + files: { + [${JSON.stringify(configKey)}]: 'export const config = "config from memory";', + [${JSON.stringify(generatedKey)}]: 'export const generated = "generated in memory";', + }, + }; + ${report(["config from disk", "config from memory", "generated in memory"])} + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify({ success: true, logs: [], found: ["config from memory", "generated in memory"] }), + }); + }); + + // An entrypoint spelled one way and keyed another still resolves to the + // same in-memory file, and in-memory files keyed relative to the cwd can + // import each other. Nothing here exists on disk. + test.concurrent.each([ + ["entry.js", "entry.js", "lib.js"], + ["./entry.js", "./entry.js", "./lib.js"], + ["entry.js", "./entry.js", "lib.js"], + ["./entry.js", "entry.js", "./lib.js"], + ["./src/entry.js", "./src/entry.js", "./src/lib.js"], + ["src/entry.js", "./src/entry.js", "src/lib.js"], + ["./src/entry.js", "src/entry.js", "./src/lib.js"], + ])("entrypoint %j with keys %j and %j", async (entrypoint, entryKey, libKey) => { + using dir = tempDir("bundler-files-relative-entry", { + "build.ts": ` + const options = { + entrypoints: [${JSON.stringify(entrypoint)}], + files: { + [${JSON.stringify(entryKey)}]: 'import { lib } from "./lib.js"; console.log("entry in memory", lib);', + [${JSON.stringify(libKey)}]: 'export const lib = "lib in memory";', + }, + }; + ${report(["entry in memory", "lib in memory"])} + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify({ success: true, logs: [], found: ["entry in memory", "lib in memory"] }), + }); + }); + + test.concurrent("in-memory entrypoint keyed relative to the cwd can import a file on disk", async () => { + using dir = tempDir("bundler-files-relative-entry-disk-import", { + "lib.js": `export const lib = "lib from disk";`, + "build.ts": ` + const options = { + entrypoints: ["./entry.js"], + files: { "./entry.js": 'import { lib } from "./lib.js"; console.log("entry in memory", lib);' }, + }; + ${report(["entry in memory", "lib from disk"])} + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify({ success: true, logs: [], found: ["entry in memory", "lib from disk"] }), + }); + }); + + test.concurrent.skipIf(!isWindows)("keys and entrypoints match whatever the case of the drive letter", async () => { + using dir = tempDir("bundler-files-drive-letter", { + "build.ts": ` + import { join } from "node:path"; + const cwd = process.cwd(); + const lower = cwd[0].toLowerCase() + cwd.slice(1); + const upper = cwd[0].toUpperCase() + cwd.slice(1); + const options = { + entrypoints: [join(lower, "entry.js")], + files: { + [join(upper, "entry.js")]: 'import { lib } from "./lib.js"; console.log("entry in memory", lib);', + [join(lower, "lib.js")]: 'export const lib = "lib in memory";', + }, + }; + ${report(["entry in memory", "lib in memory"])} + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify({ success: true, logs: [], found: ["entry in memory", "lib in memory"] }), + }); + }); + + // A key is a path, so an importer in another directory is not matched by + // a key that merely spells the same text as its import specifier. + test.concurrent("a key only matches the file it resolves to", async () => { + using dir = tempDir("bundler-files-relative-key-is-a-path", { + "src/index.ts": ` + import { config } from "./config.ts"; + console.log(config); + `, + "src/config.ts": `export const config = "config from disk";`, + "build.ts": ` + const options = { + entrypoints: ["./src/index.ts"], + files: { "./config.ts": 'export const config = "config from memory";' }, + }; + ${report(["config from disk", "config from memory"])} + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify({ success: true, logs: [], found: ["config from disk"] }), + }); + }); + + // Longer than MAX_PATH_BYTES on every platform (Windows allows the most, + // 32767 UTF-16 units * 3 bytes). + const longerThanAnyPath = Buffer.alloc(100_000, "x").toString(); + + test.concurrent("a key that resolves to a path longer than the platform allows is rejected", async () => { + using dir = tempDir("bundler-files-key-too-long", { + "build.ts": ` + const key = ${JSON.stringify(longerThanAnyPath)} + ".js"; + try { + Bun.build({ entrypoints: [key], files: { [key]: "" } }); + console.log("did not throw"); + } catch (error) { + console.log(JSON.stringify({ name: error.name, message: error.message.replace(/\\d+/, "N") })); + } + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify({ name: "TypeError", message: "files: key resolves to a path longer than N bytes" }), + }); + }); + + test.concurrent("an import specifier longer than the platform allows is a resolve error, not a crash", async () => { + using dir = tempDir("bundler-files-specifier-too-long", { + "build.ts": ` + const long = ${JSON.stringify(longerThanAnyPath)}; + const result = await Bun.build({ + entrypoints: ["entry.js"], + files: { "entry.js": 'import "./' + long + '.js";' }, + throw: false, + }); + console.log(JSON.stringify({ + success: result.success, + logs: result.logs.map(log => String(log).replaceAll(long, "")), + })); + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify({ success: false, logs: ['ResolveMessage: Could not resolve: "./.js"'] }), + }); + }); + }); }); From e9d6f298eaf7a611bc81574454de5178a1473cef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:43:31 +0000 Subject: [PATCH 2/6] test: cover a syntax error in an entry point keyed relative to the cwd --- test/bundler/bundler_files.test.ts | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/bundler/bundler_files.test.ts b/test/bundler/bundler_files.test.ts index 197dadc11371..a992fbe0c35c 100644 --- a/test/bundler/bundler_files.test.ts +++ b/test/bundler/bundler_files.test.ts @@ -690,6 +690,39 @@ describe("bundler files option", () => { }); }); + // The error is reported against the path the key resolved to. + test.concurrent("a syntax error in an entrypoint keyed relative to the cwd is a build error", async () => { + using dir = tempDir("bundler-files-relative-entry-syntax-error", { + "build.ts": ` + import { isAbsolute, relative } from "node:path"; + const result = await Bun.build({ + entrypoints: ["./entry.js"], + files: { "./entry.js": ")" }, + throw: false, + }); + console.log(JSON.stringify({ + success: result.success, + logs: result.logs.map(String), + files: result.logs.map(log => { + const file = log.position.file; + return { absolute: isAbsolute(file), fromCwd: relative(process.cwd(), file) }; + }), + })); + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify({ + success: false, + logs: ["BuildMessage: Unexpected )"], + files: [{ absolute: true, fromCwd: "entry.js" }], + }), + }); + }); + test.concurrent.skipIf(!isWindows)("keys and entrypoints match whatever the case of the drive letter", async () => { using dir = tempDir("bundler-files-drive-letter", { "build.ts": ` From 27fd93fc5ec930e1e13a6bc11c8769b7279feed2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:49:29 +0000 Subject: [PATCH 3/6] bundler: trim FileMap comments to one-liners --- src/bundler/bundle_v2.rs | 49 +++++++--------------------------------- 1 file changed, 8 insertions(+), 41 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 392f19bca811..7497d6120379 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -869,23 +869,12 @@ pub mod bv2_impl { } } - /// Mirrors `JSBundler.FileMap` — virtual in-memory files for the build. - /// The bundler only ever reads `.slice()`, so the moved-down map - /// stores raw bytes. - /// `bun_runtime`'s `from_js` parses JS values via `BlobOrStringOrBuffer` - /// in async (owning-copy) mode and inserts the extracted bytes via - /// [`FileMap::put`]. - /// - /// Keys are stored in the form [`FileMap::canonical`] produces, and - /// every lookup canonicalizes its input the same way, so a key and a - /// path can only ever be compared in one spelling. + /// Mirrors `JSBundler.FileMap`: in-memory build files, keyed as [`FileMap::canonical`] spells them. #[derive(Default)] pub struct FileMap { pub map: bun_collections::StringHashMap>, } impl FileMap { - /// Fails with [`bun_paths::Error::MaxPathExceeded`] when the - /// resolved `path` does not fit in a path buffer. pub fn put(&mut self, path: &[u8], contents: Box<[u8]>) -> bun_paths::Result<()> { let mut buf = bun_paths::path_buffer_pool::get(); let key = Self::canonical(path, &mut **buf) @@ -894,13 +883,7 @@ pub mod bv2_impl { Ok(()) } - /// The one spelling of a path that keys are stored under: `/` - /// separators (plus an uppercase drive letter on Windows), and a - /// non-absolute path resolved against the cwd, which is also what - /// a non-absolute entry point is resolved against. An absolute - /// path keeps its text, so it is also the `Path.text` an - /// in-memory source ends up with. `None` if the result does not - /// fit in `buf`. + /// Canonical key: `/` separators; a non-absolute path is resolved against the cwd. fn canonical<'b>(path: &[u8], buf: &'b mut [u8]) -> Option<&'b [u8]> { let mut scratch = bun_paths::path_buffer_pool::get(); let posix = scratch.get_mut(..path.len())?; @@ -933,9 +916,7 @@ pub mod bv2_impl { .map(|(key, contents)| (key.as_ref(), contents.as_ref())) } - /// `path` is an entry point or a source path, i.e. a path that - /// stands on its own (relative to the cwd), not an import - /// specifier. + /// `path` stands on its own (entry point or source path), not an import specifier. fn lookup(&self, path: &[u8]) -> Option<(&[u8], &[u8])> { if self.map.is_empty() { return None; @@ -953,17 +934,7 @@ pub mod bv2_impl { self.lookup(path).is_some() } - /// Returns a `resolver::Result` for a file in the map, or `None` if - /// not found. `specifier` is looked up as a path of its own when it - /// is absolute or an entry point (`source_file` is empty), and - /// otherwise joined against `dirname(source_file)` like the - /// resolver would. - /// - /// `arena` is the build's bump arena (`BundleV2::arena()`); - /// the matched key is copied into it so the returned - /// `bun_resolver::Result`'s `Path<'static>` borrows arena memory - /// (lives for the entire build pass) instead of the map's key - /// storage. + /// Resolver hook: a specifier that matches an in-memory file resolves to its map key. pub(crate) fn resolve( &self, arena: &bun_alloc::Arena, @@ -1004,8 +975,7 @@ pub mod bv2_impl { source_file: &[u8], specifier: &[u8], ) -> Option<(&[u8], &[u8])> { - // `source_file` may itself be relative (e.g. on Windows - // when the bundler stores paths relative to cwd). + // `source_file` may itself be relative (Windows stores bundler paths cwd-relative). let mut abs_source_buf = bun_paths::path_buffer_pool::get(); let abs_source_file: &[u8] = if bun_paths::is_absolute_loose(source_file) { source_file @@ -1014,8 +984,7 @@ pub mod bv2_impl { .abs_buf(&[source_file], &mut *abs_source_buf) }; - // Normalize `source_file` to forward slashes (Windows paths - // from the real filesystem may use backslashes). + // Filesystem paths may use backslashes on Windows. let mut source_file_buf = bun_paths::path_buffer_pool::get(); let normalized_source_file = bun_paths::resolve_path::path_to_posix_buf::( abs_source_file, @@ -1025,8 +994,7 @@ pub mod bv2_impl { let source_dir = bun_paths::resolve_path::dirname::( normalized_source_file, ); - // If `dirname` returns empty but the path has a drive - // letter, use the drive root. + // Empty `dirname`: fall back to the drive root, "/", or the cwd. let effective_source_dir: &[u8] = if source_dir.is_empty() { if normalized_source_file.len() >= 3 && normalized_source_file[1] == b':' @@ -1043,8 +1011,7 @@ pub mod bv2_impl { } else { source_dir }; - // `.loose` preserves Windows drive letters. A specifier too - // long to join is not in the map; the resolver reports it. + // `.loose` preserves drive letters; an overlong join means no match. let mut buf = bun_paths::path_buffer_pool::get(); let joined = bun_paths::resolve_path::join_abs_string_buf_checked::< From 1abaa957b448b372c5afb76770878ddff18399a3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:37:06 +0000 Subject: [PATCH 4/6] ci: retrigger From 591350c79a9ed5abc592d53946e829c63f852c0c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:50:30 +0000 Subject: [PATCH 5/6] bundler: bounds-check the importer path in FileMap::lookup_import The importer was copied into two path buffers unchecked, so a plugin module whose path is longer than a path buffer crashed any build that sets `files` as soon as it imported a relative specifier. Canonicalize the importer the same way keys are, which is bounds-checked, take its dirname, and probe the map with the joined path directly instead of canonicalizing it a second time. Also cover relative keys overriding files the disk resolver picks (extensionless and package imports), which only works through FileMap::get. --- src/bundler/bundle_v2.rs | 59 +++++++------------------ test/bundler/bundler_files.test.ts | 71 ++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 43 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 7497d6120379..a6ebd5585d47 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -969,55 +969,28 @@ pub mod bv2_impl { }) } - /// A relative (or bare) `specifier` imported by `source_file`. + /// A relative (or bare) `specifier` imported by `source_file`. An importer or a + /// joined path that does not fit in a path buffer is not in the map. fn lookup_import( &self, source_file: &[u8], specifier: &[u8], ) -> Option<(&[u8], &[u8])> { - // `source_file` may itself be relative (Windows stores bundler paths cwd-relative). - let mut abs_source_buf = bun_paths::path_buffer_pool::get(); - let abs_source_file: &[u8] = if bun_paths::is_absolute_loose(source_file) { - source_file - } else { - bun_resolver::fs::FileSystem::get() - .abs_buf(&[source_file], &mut *abs_source_buf) - }; - - // Filesystem paths may use backslashes on Windows. - let mut source_file_buf = bun_paths::path_buffer_pool::get(); - let normalized_source_file = bun_paths::resolve_path::path_to_posix_buf::( - abs_source_file, - &mut **source_file_buf, - ); - - let source_dir = bun_paths::resolve_path::dirname::( - normalized_source_file, - ); - // Empty `dirname`: fall back to the drive root, "/", or the cwd. - let effective_source_dir: &[u8] = if source_dir.is_empty() { - if normalized_source_file.len() >= 3 - && normalized_source_file[1] == b':' - && normalized_source_file[2] == b'/' - { - &normalized_source_file[0..3] // "C:/" - } else if !normalized_source_file.is_empty() - && normalized_source_file[0] == b'/' - { - b"/" - } else { - bun_resolver::fs::FileSystem::get().top_level_dir - } - } else { - source_dir - }; - // `.loose` preserves drive letters; an overlong join means no match. + // `source_file` may be cwd-relative (Windows stores bundler paths that way). + let mut importer_buf = bun_paths::path_buffer_pool::get(); + let importer = Self::canonical(source_file, &mut **importer_buf)?; + let source_dir = bun_paths::dirname(importer)?; + // `.loose` preserves drive letters. let mut buf = bun_paths::path_buffer_pool::get(); - let joined = - bun_paths::resolve_path::join_abs_string_buf_checked::< - bun_paths::platform::Loose, - >(effective_source_dir, &mut **buf, &[specifier])?; - self.lookup(joined) + let len = bun_paths::resolve_path::join_abs_string_buf_checked::< + bun_paths::platform::Loose, + >(source_dir, &mut **buf, &[specifier])? + .len(); + let joined = &mut buf[..len]; + bun_paths::resolve_path::dangerously_convert_path_to_posix_in_place::( + joined, + ); + self.entry(joined) } } diff --git a/test/bundler/bundler_files.test.ts b/test/bundler/bundler_files.test.ts index a992fbe0c35c..30f97dedb8bc 100644 --- a/test/bundler/bundler_files.test.ts +++ b/test/bundler/bundler_files.test.ts @@ -637,6 +637,38 @@ describe("bundler files option", () => { }); }); + // These imports do not spell out the file, so the disk resolver picks the + // file and the key has to match the path it comes back with. + test.concurrent("keys override files the resolver finds for extensionless and package imports", async () => { + using dir = tempDir("bundler-files-relative-override-resolved", { + "src/index.ts": ` + import { config } from "./config"; + import { util } from "util-lib"; + console.log(config, util); + `, + "src/config.ts": `export const config = "config from disk";`, + "node_modules/util-lib/package.json": JSON.stringify({ name: "util-lib", main: "index.js" }), + "node_modules/util-lib/index.js": `export const util = "util from disk";`, + "build.ts": ` + const options = { + entrypoints: ["./src/index.ts"], + files: { + "./src/config.ts": 'export const config = "config from memory";', + "./node_modules/util-lib/index.js": 'export const util = "util from memory";', + }, + }; + ${report(["config from disk", "config from memory", "util from disk", "util from memory"])} + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify({ success: true, logs: [], found: ["config from memory", "util from memory"] }), + }); + }); + // An entrypoint spelled one way and keyed another still resolves to the // same in-memory file, and in-memory files keyed relative to the cwd can // import each other. Nothing here exists on disk. @@ -823,5 +855,44 @@ describe("bundler files option", () => { stdout: JSON.stringify({ success: false, logs: ['ResolveMessage: Could not resolve: "./.js"'] }), }); }); + + // A plugin can give a module any path it likes. One that does not fit in a + // path buffer cannot be joined with the module's imports, so `files` stays + // out of the way and the import fails to resolve as it would without it. + test.concurrent( + "an importer with a path longer than the platform allows is a resolve error, not a crash", + async () => { + using dir = tempDir("bundler-files-importer-too-long", { + "entry.js": `import "long";`, + "build.ts": ` + const results = []; + for (const long of [${JSON.stringify(longerThanAnyPath)}, ${JSON.stringify("/" + longerThanAnyPath)}]) { + const result = await Bun.build({ + entrypoints: ["./entry.js"], + files: { "./lib.js": "" }, + plugins: [{ + name: "long", + setup(build) { + build.onResolve({ filter: /^long$/ }, () => ({ path: long, namespace: "long" })); + build.onLoad({ filter: /./, namespace: "long" }, () => ({ contents: 'import "./lib.js";', loader: "js" })); + }, + }], + throw: false, + }); + results.push({ success: result.success, logs: result.logs.map(log => String(log).replaceAll(long, "")) }); + } + console.log(JSON.stringify(results)); + `, + }); + + const { stdout, stderr, exitCode } = await bunRun(`${dir}/build.ts`); + const unresolved = { success: false, logs: ['ResolveMessage: Could not resolve: "./lib.js"'] }; + expect({ stderr, exitCode, stdout }).toEqual({ + stderr: "", + exitCode: 0, + stdout: JSON.stringify([unresolved, unresolved]), + }); + }, + ); }); }); From 0ce36e30420916ea45bca9d5b559e332926eedd3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:08:37 +0000 Subject: [PATCH 6/6] bundler: trim the lookup_import doc to one line --- src/bundler/bundle_v2.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index a6ebd5585d47..a69ad8112e2e 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -969,8 +969,7 @@ pub mod bv2_impl { }) } - /// A relative (or bare) `specifier` imported by `source_file`. An importer or a - /// joined path that does not fit in a path buffer is not in the map. + /// A relative (or bare) `specifier` imported by `source_file`; an overlong path is not in the map. fn lookup_import( &self, source_file: &[u8],