diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 69a5625b1974..a5516174e964 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4122,24 +4122,23 @@ 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) { + template.placeholder.dir = options::source_dir_relative_to_root( + pathname.dir, + &self.transpiler.options.root_dir, + source.path.is_file(), + )?; + } + if template.needs(options::PlaceholderField::Hash) { template.placeholder.hash = Some(content_hashes_for_additional_files[index]); diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index 07de93c10b1e..00d5a0191bb1 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,11 @@ 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, + true, + )?; } } diff --git a/src/bundler/options.rs b/src/bundler/options.rs index af6271a8133f..e848bed2302c 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -2480,6 +2480,41 @@ pub enum PlaceholderField { Target, } +/// `[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() { + b"." + } else { + source_dir + }; + 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); + } + } + } + Ok(Box::<[u8]>::from(rel)) +} + // Shared body for PathTemplate::needs / PathTemplateConst::needs (D064). #[inline] pub(crate) fn path_template_needs(data: &[u8], field: PlaceholderField) -> bool { diff --git a/test/bundler/bundler_naming.test.ts b/test/bundler/bundler_naming.test.ts index a51451b19cbe..1f5a531d4f06 100644 --- a/test/bundler/bundler_naming.test.ts +++ b/test/bundler/bundler_naming.test.ts @@ -1,4 +1,7 @@ -import { describe } from "bun:test"; +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", () => { @@ -191,6 +194,108 @@ describe("bundler", () => { }, ], }); + // `[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": "", + "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]); + + const out = stdout.replaceAll("\\", "/"); + const assetLine = out.split("\n").find(l => l.startsWith("asset ")); + expect({ assetLine, stderr, exitCode }).toEqual({ + assetLine: "asset ./lib/second/test.file", + stderr: "", + exitCode: 0, + }); + 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: {