From 6a831a563d8bf59ccb39a47cb1dd4b6f9f00eda5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:08:43 +0000 Subject: [PATCH 01/12] bundler: canonicalize asset source directory before computing [dir] placeholder The configured root directory is canonicalized via get_fd_path (the kernel-resolved path) when the bundler is set up, but the asset source path was relativized against it as-is. On Windows the cwd (and so every resolver-produced source path) routinely carries 8.3 short path components such as C:\Users\RUNNER~1, so the two spellings share no common prefix and [dir] expanded to a long _.._/_.._/... traversal back into the temp directory. The same thing happens on POSIX for a Bun.build files-map key that names a symlinked directory. Resolve the asset's directory through get_fd_path before relativizing, the same way compute_chunks already does for entry/chunk [dir]. --- src/bundler/bundle_v2.rs | 52 ++++++++++++++++++----- test/bundler/bundler_naming.test.ts | 64 ++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 69a5625b1974..86447e7f8604 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4122,24 +4122,56 @@ pub mod bv2_impl { let source = &mut sources[index]; let output_path: Box<[u8]> = { - // TODO: outbase - let pathname = - Fs::PathName::init(bun_paths::resolve_path::relative_platform::< - bun_paths::resolve_path::platform::Loose, - false, - >( - &self.transpiler.options.root_dir, - source.path.text, - )); + let pathname = Fs::PathName::init(source.path.text); template.placeholder.name = pathname.base.to_vec().into_boxed_slice(); - template.placeholder.dir = pathname.dir.to_vec().into_boxed_slice(); let mut ext: &[u8] = pathname.ext; if !ext.is_empty() && ext[0] == b'.' { ext = &ext[1..]; } template.placeholder.ext = ext.to_vec().into_boxed_slice(); + if template.needs(options::PlaceholderField::Dir) { + // `root_dir` was canonicalized via `get_fd_path` + // when the bundler was configured; resolve the + // asset's directory the same way so the relative + // path lines up on Windows even when the cwd + // (and thus `source.path.text`) carries 8.3 + // short names. Mirrors `compute_chunks`. + let source_dir: &[u8] = if pathname.dir.is_empty() { + b"." + } else { + pathname.dir + }; + 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(), + source_dir, + bun_sys::O::PATH | bun_sys::O::DIRECTORY, + 0, + ) else { + break 'dir &*bun_paths::resolve_path::normalize_buf::< + bun_paths::platform::Auto, + >( + source_dir, &mut real_path_buf.0 + ); + }; + match dir_file.get_path(&mut real_path_buf) { + Ok(p) => p, + Err(_) => &*bun_paths::resolve_path::normalize_buf::< + bun_paths::platform::Auto, + >( + source_dir, &mut real_path_buf.0 + ), + } + }; + template.placeholder.dir = bun_paths::resolve_path::relative_alloc( + &self.transpiler.options.root_dir, + dir, + )?; + } + if template.needs(options::PlaceholderField::Hash) { template.placeholder.hash = Some(content_hashes_for_additional_files[index]); diff --git a/test/bundler/bundler_naming.test.ts b/test/bundler/bundler_naming.test.ts index a51451b19cbe..558779869c5a 100644 --- a/test/bundler/bundler_naming.test.ts +++ b/test/bundler/bundler_naming.test.ts @@ -1,5 +1,8 @@ -import { describe } from "bun:test"; +import { describe, test, expect } from "bun:test"; import { ESBUILD, itBundled } from "./expectBundled"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { mkdirSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; describe("bundler", () => { itBundled("naming/EntryNamingCollission", { @@ -191,6 +194,65 @@ describe("bundler", () => { }, ], }); + // assetNaming `[dir]` must be relative to the configured root even when + // the root directory and the asset's source path spell the same on-disk + // location differently. Bun canonicalizes `root` via the file descriptor + // (`GetFinalPathNameByHandle` on Windows, /proc/self/fd on Linux) but + // `Bun.build({ files })` source paths are the literal map keys; previously + // the uncanonicalized source path was relativized against the canonical + // root, so no common prefix was found and `[dir]` expanded to a long + // `_.._/_.._/...` traversal back into the source tree. On Windows this + // surfaced whenever the cwd contained an 8.3 short path component such as + // `C:\Users\RUNNER~1\...` (the default TEMP directory in CI). + test("naming/AssetNamingDirCanonicalRoot", async () => { + using base = tempDir("asset-naming-dir-canon", { + "real/src/lib/first/.keep": "", + "real/src/lib/second/.keep": "", + }); + const real = join(String(base), "real"); + const link = join(String(base), "project-link"); + // A junction needs no elevation on Windows; on POSIX this is a plain + // directory symlink. Either way `root` below canonicalizes to `real/src` + // while the `files` map keys keep the `project-link` spelling. + symlinkSync(real, link, isWindows ? "junction" : "dir"); + + const entry = join(link, "src/lib/first/file.js").replaceAll("\\", "/"); + const asset = join(link, "src/lib/second/data.file").replaceAll("\\", "/"); + const root = join(link, "src"); + + const script = ` + const result = await Bun.build({ + entrypoints: [${JSON.stringify(entry)}], + files: { + ${JSON.stringify(entry)}: 'import f from "../second/data.file"; console.log(f);', + ${JSON.stringify(asset)}: "this is a file", + }, + root: ${JSON.stringify(root)}, + naming: { entry: "hello.[ext]", asset: "[dir]/test.[ext]" }, + loader: { ".file": "file" }, + }); + if (!result.success) { + for (const m of result.logs) console.error(String(m)); + process.exit(1); + } + for (const out of result.outputs) console.log(out.kind + " " + out.path); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + cwd: String(base), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const assetLine = stdout.split("\n").find(l => l.startsWith("asset ")); + expect(assetLine).toBe("asset ./lib/second/test.file"); + expect(stdout).not.toContain("_.._"); + expect(exitCode).toBe(0); + }); itBundled("naming/AssetNoOverwrite", { todo: true, files: { From 0e4bd03cf5a0938e87b1506f2047c00476dd3cd9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:13:28 +0000 Subject: [PATCH 02/12] test: normalize path separators in AssetNamingDirCanonicalRoot assertion --- test/bundler/bundler_naming.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/bundler/bundler_naming.test.ts b/test/bundler/bundler_naming.test.ts index 558779869c5a..91a567b151dd 100644 --- a/test/bundler/bundler_naming.test.ts +++ b/test/bundler/bundler_naming.test.ts @@ -248,9 +248,10 @@ describe("bundler", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - const assetLine = stdout.split("\n").find(l => l.startsWith("asset ")); + const out = stdout.replaceAll("\\", "/"); + const assetLine = out.split("\n").find(l => l.startsWith("asset ")); expect(assetLine).toBe("asset ./lib/second/test.file"); - expect(stdout).not.toContain("_.._"); + expect(out).not.toContain("_.._"); expect(exitCode).toBe(0); }); itBundled("naming/AssetNoOverwrite", { From 4c90e7d5eaa3ca59de6dc77e60a3efc288ecd499 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:24:06 +0000 Subject: [PATCH 03/12] test: drop unused mkdirSync import --- test/bundler/bundler_naming.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundler/bundler_naming.test.ts b/test/bundler/bundler_naming.test.ts index 91a567b151dd..800863438615 100644 --- a/test/bundler/bundler_naming.test.ts +++ b/test/bundler/bundler_naming.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test"; import { ESBUILD, itBundled } from "./expectBundled"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; -import { mkdirSync, symlinkSync } from "node:fs"; +import { symlinkSync } from "node:fs"; import { join } from "node:path"; describe("bundler", () => { From 58bc3db8810494bfcb450ec54156c2bb9d567c3b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:26:14 +0000 Subject: [PATCH 04/12] trim comment to three lines --- src/bundler/bundle_v2.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 86447e7f8604..230eb1f168f8 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4132,12 +4132,9 @@ pub mod bv2_impl { template.placeholder.ext = ext.to_vec().into_boxed_slice(); if template.needs(options::PlaceholderField::Dir) { - // `root_dir` was canonicalized via `get_fd_path` - // when the bundler was configured; resolve the - // asset's directory the same way so the relative - // path lines up on Windows even when the cwd - // (and thus `source.path.text`) carries 8.3 - // short names. Mirrors `compute_chunks`. + // `root_dir` is already canonical (`get_fd_path`); + // canonicalize the source dir the same way so + // Windows 8.3 short names relativize correctly. let source_dir: &[u8] = if pathname.dir.is_empty() { b"." } else { From 2d96b4aa5708c94f0fd5309af171d967b547cc14 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:28:25 +0000 Subject: [PATCH 05/12] [autofix.ci] apply automated fixes --- test/bundler/bundler_naming.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/bundler/bundler_naming.test.ts b/test/bundler/bundler_naming.test.ts index 800863438615..ddabc5bb4b4f 100644 --- a/test/bundler/bundler_naming.test.ts +++ b/test/bundler/bundler_naming.test.ts @@ -1,8 +1,8 @@ -import { describe, test, expect } from "bun:test"; -import { ESBUILD, itBundled } from "./expectBundled"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { symlinkSync } from "node:fs"; import { join } from "node:path"; +import { ESBUILD, itBundled } from "./expectBundled"; describe("bundler", () => { itBundled("naming/EntryNamingCollission", { From 5cacc7ec853224675dd53f8c272b5325344efcb0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:36:06 +0000 Subject: [PATCH 06/12] test: trim comment to three lines and use combined-object assertion --- test/bundler/bundler_naming.test.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/test/bundler/bundler_naming.test.ts b/test/bundler/bundler_naming.test.ts index ddabc5bb4b4f..d34c207948d2 100644 --- a/test/bundler/bundler_naming.test.ts +++ b/test/bundler/bundler_naming.test.ts @@ -194,16 +194,9 @@ describe("bundler", () => { }, ], }); - // assetNaming `[dir]` must be relative to the configured root even when - // the root directory and the asset's source path spell the same on-disk - // location differently. Bun canonicalizes `root` via the file descriptor - // (`GetFinalPathNameByHandle` on Windows, /proc/self/fd on Linux) but - // `Bun.build({ files })` source paths are the literal map keys; previously - // the uncanonicalized source path was relativized against the canonical - // root, so no common prefix was found and `[dir]` expanded to a long - // `_.._/_.._/...` traversal back into the source tree. On Windows this - // surfaced whenever the cwd contained an 8.3 short path component such as - // `C:\Users\RUNNER~1\...` (the default TEMP directory in CI). + // `[dir]` must resolve relative to the configured root even when root and + // the asset source path spell the same directory differently (Windows 8.3 + // short names in the cwd, or a symlinked `Bun.build({ files })` key). test("naming/AssetNamingDirCanonicalRoot", async () => { using base = tempDir("asset-naming-dir-canon", { "real/src/lib/first/.keep": "", @@ -247,12 +240,14 @@ describe("bundler", () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); const out = stdout.replaceAll("\\", "/"); const assetLine = out.split("\n").find(l => l.startsWith("asset ")); - expect(assetLine).toBe("asset ./lib/second/test.file"); + expect({ assetLine, stderr, exitCode }).toEqual({ + assetLine: "asset ./lib/second/test.file", + stderr: "", + exitCode: 0, + }); expect(out).not.toContain("_.._"); - expect(exitCode).toBe(0); }); itBundled("naming/AssetNoOverwrite", { todo: true, From 4bf1b2ad24b65bd9cf5d8674da7e15af4434f805 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:50:53 +0000 Subject: [PATCH 07/12] extract source_dir_relative_to_root helper and share between compute_chunks and process_files_to_copy --- src/bundler/bundle_v2.rs | 35 +--------------- src/bundler/linker_context/computeChunks.rs | 45 ++------------------- src/bundler/options.rs | 30 ++++++++++++++ 3 files changed, 36 insertions(+), 74 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 230eb1f168f8..b5bd98a309f0 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4132,40 +4132,9 @@ pub mod bv2_impl { template.placeholder.ext = ext.to_vec().into_boxed_slice(); if template.needs(options::PlaceholderField::Dir) { - // `root_dir` is already canonical (`get_fd_path`); - // canonicalize the source dir the same way so - // Windows 8.3 short names relativize correctly. - let source_dir: &[u8] = if pathname.dir.is_empty() { - b"." - } else { - pathname.dir - }; - 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(), - source_dir, - bun_sys::O::PATH | bun_sys::O::DIRECTORY, - 0, - ) else { - break 'dir &*bun_paths::resolve_path::normalize_buf::< - bun_paths::platform::Auto, - >( - source_dir, &mut real_path_buf.0 - ); - }; - match dir_file.get_path(&mut real_path_buf) { - Ok(p) => p, - Err(_) => &*bun_paths::resolve_path::normalize_buf::< - bun_paths::platform::Auto, - >( - source_dir, &mut real_path_buf.0 - ), - } - }; - template.placeholder.dir = bun_paths::resolve_path::relative_alloc( + template.placeholder.dir = options::source_dir_relative_to_root( + pathname.dir, &self.transpiler.options.root_dir, - dir, )?; } diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index 07de93c10b1e..64fd19965d88 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -5,7 +5,6 @@ use core::sync::atomic::AtomicUsize; use bun_alloc::Arena; // bumpalo::Bump re-export use bun_collections::{ArrayHashMap, AutoBitSet, VecExt}; use bun_core::strings; -use bun_paths::{PathBuffer, resolve_path}; use bun_sourcemap::SourceMapPieces; use bun_wyhash::{self, Wyhash}; @@ -634,46 +633,10 @@ pub fn compute_chunks(this: &mut LinkerContext, unique_key: u64) -> crate::Resul } if chunk.template.needs(PlaceholderField::Dir) { - // this if check is a specific fix for `bun build hi.ts --external '*'`, without leading `./` - let dir_path: &[u8] = if !pathname.dir.is_empty() { - pathname.dir - } else { - b"." - }; - let mut real_path_buf = PathBuffer::uninit(); - 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 { - break 'dir &*resolve_path::normalize_buf::( - dir_path, - &mut real_path_buf.0, - ); - }; - - match dir_file.get_path(&mut real_path_buf) { - Ok(p) => break 'dir p, - Err(err) => { - // Split-borrow — see `LinkerContext::log_disjoint`. - this.log_disjoint().add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "{}: Failed to get full path for directory '{}'", - bstr::BStr::new(err.name()), - bstr::BStr::new(dir_path) - ), - ); - return Err(crate::Error::BuildFailed); - } - } - }; - - let root_dir = &this.resolver().opts.root_dir; - chunk.template.placeholder.dir = resolve_path::relative_alloc(root_dir, dir)?; + chunk.template.placeholder.dir = crate::options::source_dir_relative_to_root( + pathname.dir, + &this.resolver().opts.root_dir, + )?; } } diff --git a/src/bundler/options.rs b/src/bundler/options.rs index af6271a8133f..65e95f48fb53 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2480,6 +2480,36 @@ pub enum PlaceholderField { Target, } +/// `[dir]` placeholder value: canonicalize `source_dir` via `get_fd_path` +/// (so Windows 8.3 short names / symlinked prefixes match a canonical +/// `root_dir`), fall back to string normalization, then relativize. +pub(crate) fn source_dir_relative_to_root( + source_dir: &[u8], + root_dir: &[u8], +) -> Result, bun_alloc::AllocError> { + let source_dir: &[u8] = if source_dir.is_empty() { b"." } else { source_dir }; + let mut buf = bun_paths::path_buffer_pool::get(); + let dir: &[u8] = 'dir: { + let Ok(f) = bun_sys::File::openat( + bun_sys::Fd::cwd(), + source_dir, + bun_sys::O::PATH | bun_sys::O::DIRECTORY, + 0, + ) else { + break 'dir &*bun_paths::resolve_path::normalize_buf::( + source_dir, &mut buf.0, + ); + }; + match f.get_path(&mut buf) { + Ok(p) => p, + Err(_) => &*bun_paths::resolve_path::normalize_buf::( + source_dir, &mut buf.0, + ), + } + }; + bun_paths::resolve_path::relative_alloc(root_dir, dir) +} + // Shared body for PathTemplate::needs / PathTemplateConst::needs (D064). #[inline] pub(crate) fn path_template_needs(data: &[u8], field: PlaceholderField) -> bool { From 5c7de2faddc603bb686f0981f4448b5e1d6fb8fc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:53:10 +0000 Subject: [PATCH 08/12] [autofix.ci] apply automated fixes --- src/bundler/options.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 65e95f48fb53..9a90a9810813 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2487,7 +2487,11 @@ pub(crate) fn source_dir_relative_to_root( source_dir: &[u8], root_dir: &[u8], ) -> Result, bun_alloc::AllocError> { - let source_dir: &[u8] = if source_dir.is_empty() { b"." } else { source_dir }; + let source_dir: &[u8] = if source_dir.is_empty() { + b"." + } else { + source_dir + }; let mut buf = bun_paths::path_buffer_pool::get(); let dir: &[u8] = 'dir: { let Ok(f) = bun_sys::File::openat( From 4e9aee3d8b79b9d2b6856ac7d993a7c7bc8b1ea6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:07:47 +0000 Subject: [PATCH 09/12] gate asset [dir] canonicalization on the file namespace A plugin-emitted asset in a virtual namespace can have a path whose dirname coincidentally matches an on-disk directory relative to cwd; opening that directory and relativizing its real path would make the output depend on unrelated disk state. Only touch the filesystem when source.path.is_file(); virtual sources keep the pure-string resolution they had before. compute_chunks passes true to preserve its existing behavior for entry chunks. Add naming/AssetNamingDirVirtualNamespace to guard this. --- src/bundler/bundle_v2.rs | 1 + src/bundler/linker_context/computeChunks.rs | 1 + src/bundler/options.rs | 34 +++++++-------- test/bundler/bundler_naming.test.ts | 47 +++++++++++++++++++++ 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index b5bd98a309f0..a5516174e964 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4135,6 +4135,7 @@ pub mod bv2_impl { template.placeholder.dir = options::source_dir_relative_to_root( pathname.dir, &self.transpiler.options.root_dir, + source.path.is_file(), )?; } diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index 64fd19965d88..00d5a0191bb1 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -636,6 +636,7 @@ pub fn compute_chunks(this: &mut LinkerContext, unique_key: u64) -> crate::Resul chunk.template.placeholder.dir = crate::options::source_dir_relative_to_root( pathname.dir, &this.resolver().opts.root_dir, + true, )?; } } diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 9a90a9810813..fb580793656e 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2480,12 +2480,13 @@ pub enum PlaceholderField { Target, } -/// `[dir]` placeholder value: canonicalize `source_dir` via `get_fd_path` -/// (so Windows 8.3 short names / symlinked prefixes match a canonical -/// `root_dir`), fall back to string normalization, then relativize. +/// `[dir]` placeholder value: when `on_disk`, canonicalize `source_dir` via +/// `get_fd_path` (so Windows 8.3 short names / symlinked prefixes match a +/// canonical `root_dir`); otherwise string-normalize. Then relativize. pub(crate) fn source_dir_relative_to_root( source_dir: &[u8], root_dir: &[u8], + on_disk: bool, ) -> Result, bun_alloc::AllocError> { let source_dir: &[u8] = if source_dir.is_empty() { b"." @@ -2494,22 +2495,19 @@ pub(crate) fn source_dir_relative_to_root( }; let mut buf = bun_paths::path_buffer_pool::get(); let dir: &[u8] = 'dir: { - let Ok(f) = bun_sys::File::openat( - bun_sys::Fd::cwd(), - source_dir, - bun_sys::O::PATH | bun_sys::O::DIRECTORY, - 0, - ) else { - break 'dir &*bun_paths::resolve_path::normalize_buf::( - source_dir, &mut buf.0, - ); - }; - match f.get_path(&mut buf) { - Ok(p) => p, - Err(_) => &*bun_paths::resolve_path::normalize_buf::( - source_dir, &mut buf.0, - ), + if on_disk { + if let Ok(f) = bun_sys::File::openat( + bun_sys::Fd::cwd(), + source_dir, + bun_sys::O::PATH | bun_sys::O::DIRECTORY, + 0, + ) { + if let Ok(p) = f.get_path(&mut buf) { + break 'dir p; + } + } } + &*bun_paths::resolve_path::normalize_buf::(source_dir, &mut buf.0) }; bun_paths::resolve_path::relative_alloc(root_dir, dir) } diff --git a/test/bundler/bundler_naming.test.ts b/test/bundler/bundler_naming.test.ts index d34c207948d2..1f5a531d4f06 100644 --- a/test/bundler/bundler_naming.test.ts +++ b/test/bundler/bundler_naming.test.ts @@ -249,6 +249,53 @@ describe("bundler", () => { }); expect(out).not.toContain("_.._"); }); + // The on-disk canonicalization above must not apply to a plugin asset whose + // virtual path happens to collide with a real cwd subdirectory: `[dir]` for + // a non-file namespace stays a pure string computation. + test("naming/AssetNamingDirVirtualNamespace", async () => { + using base = tempDir("asset-naming-dir-virt", { + "src/.keep": "", + "unrelated-target/.keep": "", + }); + symlinkSync(join(String(base), "unrelated-target"), join(String(base), "assets"), isWindows ? "junction" : "dir"); + + const entry = join(String(base), "src/entry.js").replaceAll("\\", "/"); + const script = ` + const result = await Bun.build({ + entrypoints: [${JSON.stringify(entry)}], + files: { ${JSON.stringify(entry)}: 'import f from "virt:assets/icon.bin"; console.log(f);' }, + root: ${JSON.stringify(join(String(base), "src"))}, + naming: { entry: "hello.[ext]", asset: "[dir]/[name].[ext]" }, + plugins: [{ + name: "virt", + setup(b) { + b.onResolve({ filter: /^virt:/ }, a => ({ path: a.path.slice(5), namespace: "virt" })); + b.onLoad({ filter: /.*/, namespace: "virt" }, () => ({ contents: "hi", loader: "file" })); + }, + }], + }); + if (!result.success) { for (const m of result.logs) console.error(String(m)); process.exit(1); } + for (const out of result.outputs) console.log(out.kind + " " + out.path); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + cwd: String(base), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const out = stdout.replaceAll("\\", "/"); + const assetLine = out.split("\n").find(l => l.startsWith("asset ")); + expect({ assetLine, stderr, exitCode }).toEqual({ + assetLine: "asset ./_.._/assets/icon.bin", + stderr: "", + exitCode: 0, + }); + expect(out).not.toContain("unrelated-target"); + }); itBundled("naming/AssetNoOverwrite", { todo: true, files: { From 6bb639db285c3527eaea825f0ebcc6660f430707 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:09:56 +0000 Subject: [PATCH 10/12] [autofix.ci] apply automated fixes --- src/bundler/options.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bundler/options.rs b/src/bundler/options.rs index fb580793656e..67c74cd27e18 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2507,7 +2507,9 @@ pub(crate) fn source_dir_relative_to_root( } } } - &*bun_paths::resolve_path::normalize_buf::(source_dir, &mut buf.0) + &*bun_paths::resolve_path::normalize_buf::( + source_dir, &mut buf.0, + ) }; bun_paths::resolve_path::relative_alloc(root_dir, dir) } From d98a7b95810a6e293aff230115a9c6b49f811ef8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:16:46 +0000 Subject: [PATCH 11/12] carry the bare-filename regression comment onto the empty-dir guard in the shared helper --- src/bundler/options.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 67c74cd27e18..458beca3b180 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2488,6 +2488,8 @@ pub(crate) fn source_dir_relative_to_root( root_dir: &[u8], on_disk: bool, ) -> Result, bun_alloc::AllocError> { + // Empty for a bare-filename entry (`bun build hi.ts --external '*'` + // with no leading `./`); openat needs "." not "". let source_dir: &[u8] = if source_dir.is_empty() { b"." } else { From 4f0eff51fd7c79c6c68851e743774f02defcdbcc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:59:20 +0000 Subject: [PATCH 12/12] canonicalize source_dir only when the string relativize falls outside root The pure abs_buf approach regressed builds whose resolver-realpath'd source paths no longer shared a prefix with an uncanonical root_dir. Keep root_dir canonical (as on main) and make the helper try the plain string relativize first; only when that walks above root (the Windows 8.3 / symlinked-root case this PR targets) does it openat+get_fd_path the source dir. The common case where source paths already sit under the canonical root is now zero filesystem calls, and compute_chunks no longer pays its previous unconditional per-chunk openat either. --- src/bundler/options.rs | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 458beca3b180..e848bed2302c 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2480,14 +2480,15 @@ pub enum PlaceholderField { Target, } -/// `[dir]` placeholder value: when `on_disk`, canonicalize `source_dir` via -/// `get_fd_path` (so Windows 8.3 short names / symlinked prefixes match a -/// canonical `root_dir`); otherwise string-normalize. Then relativize. +/// `[dir]` placeholder value: relativize `source_dir` against `root_dir`. +/// Canonicalizes `source_dir` via `get_fd_path` only when the plain string +/// relativization falls outside `root_dir` and `on_disk` is set. pub(crate) fn source_dir_relative_to_root( source_dir: &[u8], root_dir: &[u8], on_disk: bool, ) -> Result, bun_alloc::AllocError> { + use bun_paths::resolve_path; // Empty for a bare-filename entry (`bun build hi.ts --external '*'` // with no leading `./`); openat needs "." not "". let source_dir: &[u8] = if source_dir.is_empty() { @@ -2495,25 +2496,23 @@ pub(crate) fn source_dir_relative_to_root( } else { source_dir }; - let mut buf = bun_paths::path_buffer_pool::get(); - let dir: &[u8] = 'dir: { - if on_disk { - if let Ok(f) = bun_sys::File::openat( - bun_sys::Fd::cwd(), - source_dir, - bun_sys::O::PATH | bun_sys::O::DIRECTORY, - 0, - ) { - if let Ok(p) = f.get_path(&mut buf) { - break 'dir p; - } + let rel = resolve_path::relative_platform::( + root_dir, source_dir, + ); + if on_disk && rel.starts_with(b"..") { + let mut buf = bun_paths::path_buffer_pool::get(); + if let Ok(f) = bun_sys::File::openat( + bun_sys::Fd::cwd(), + source_dir, + bun_sys::O::PATH | bun_sys::O::DIRECTORY, + 0, + ) { + if let Ok(p) = f.get_path(&mut buf) { + return resolve_path::relative_alloc(root_dir, p); } } - &*bun_paths::resolve_path::normalize_buf::( - source_dir, &mut buf.0, - ) - }; - bun_paths::resolve_path::relative_alloc(root_dir, dir) + } + Ok(Box::<[u8]>::from(rel)) } // Shared body for PathTemplate::needs / PathTemplateConst::needs (D064).