diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 0470a8e38238..4e98d96fa73d 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -1664,7 +1664,7 @@ impl<'a> BundleOptions<'a> { origin: bun_url::OwnedURL::from_href(Box::default()), output_dir_handle: None, root_dir: Box::default(), - preserve_symlinks: false, + preserve_symlinks: transform.preserve_symlinks.unwrap_or(false), preserve_extensions: false, production: false, output_format: Format::Esm, diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 6f69f5b977f4..72ea3cc9db19 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3988,6 +3988,20 @@ impl VirtualMachine { slice } + /// Canonicalize the entry point's path without going through the + /// resolver, whose preserve-symlinks mode shapes its shared caches. + /// `None` when the path can't be opened; the caller falls back to the + /// resolved spelling. + fn realpath_for_main(&mut self, path: &[u8]) -> Option<&'static [u8]> { + let boxed = bun_sys::realpath_by_open(path)?; + // SAFETY: `boxed`'s heap allocation has a stable address for as long + // as the owning `Box` lives in `resolved_path_dups` (drained in + // `destroy()`). + let slice: &'static [u8] = unsafe { core::mem::transmute::<&[u8], &'static [u8]>(&*boxed) }; + self.resolved_path_dups.push(boxed); + Some(slice) + } + /// Note: `is_a_file_path` is a runtime /// arg to avoid duplicating the body for both monomorphizations. pub(crate) fn _resolve( @@ -4082,6 +4096,17 @@ impl VirtualMachine { top_level_dir }; + // Node applies --preserve-symlinks-main / NODE_PRESERVE_SYMLINKS_MAIN + // (not --preserve-symlinks) to the entry point, which is resolved from + // the synthetic main module here (workers included). Applied after the + // resolve (see `path_text` below) rather than by flipping + // `opts.preserve_symlinks` for one call: that option shapes cached + // `DirInfo` entries, and the dir cache is keyed by path only. + let main_preserve: Option = (source == MAIN_FILE_NAME).then(|| { + bun_options_types::context::try_get() + .is_some_and(|c| c.runtime_options.preserve_symlinks_main_enabled()) + }); + // A `loop` // returning the resolver result; `retry_on_not_found` is consumed on // the first miss. @@ -4168,10 +4193,23 @@ impl VirtualMachine { let result_path = result .path_const() .ok_or(crate::CrateError::ModuleNotFound)?; - // SAFETY: `result_path.text` borrows the resolver's arena, which - // outlives `ResolveFunctionResult` (see the struct's lifetime-erasure - // note). - ret.path = unsafe { bun_ptr::detach_lifetime(result_path.text) }; + let path_text: &[u8] = match main_preserve { + // The resolver realpathed the entry and `set_realpath` kept the + // link spelling in `pretty`; that spelling is the entry's identity. + Some(true) if result_path.is_symlink && !result_path.pretty.is_empty() => { + result_path.pretty + } + // The preserve-mode resolver kept the entry's link path, but Node + // realpaths the entry when only the general flag is on. + Some(false) if self.transpiler.resolver.opts.preserve_symlinks => self + .realpath_for_main(result_path.text) + .unwrap_or(result_path.text), + _ => result_path.text, + }; + // SAFETY: `result_path.text`/`.pretty` borrow the resolver's arena, + // which outlives `ResolveFunctionResult` (see the struct's + // lifetime-erasure note). + ret.path = unsafe { bun_ptr::detach_lifetime(path_text) }; ret.result = Some(result); Ok(()) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 6e67c931d47c..3a19fd854035 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -528,6 +528,7 @@ impl WebWorker { utf8_slice.slice(), error_message, temp_log, + false, ) } { preloads.push(preload.to_vec().into_boxed_slice()); @@ -1060,6 +1061,7 @@ impl WebWorker { &self.unresolved_specifier, &mut resolve_error, vm_log, + true, ) } { Some(p) => p, @@ -1618,6 +1620,7 @@ unsafe fn resolve_entry_point_specifier<'s>( str: &'s [u8], error_message: &mut BunString, log: &mut bun_ast::Log, + is_main_entry: bool, ) -> Option<&'s [u8]> { // SAFETY: per fn contract; read-only field. if let Some(graph) = unsafe { (*parent).standalone_module_graph } { @@ -1740,7 +1743,20 @@ unsafe fn resolve_entry_point_specifier<'s>( // `filename_store` (`Path<'static>`), NOT `resolved_entry_point` itself — // copy the slice out and let `resolved_entry_point` drop on the stack. match resolved_entry_point.path_const() { - Some(entry_path) => Some(entry_path.text), + Some(entry_path) => { + // Node applies --preserve-symlinks-main to a worker's entry too + // (but not to preloads): recover the link spelling that + // `set_realpath` stashed in `pretty`. (With --preserve-symlinks + // off and -main on, the resolver above ran in realpath mode.) + let preserve_main = is_main_entry + && bun_options_types::context::try_get() + .is_some_and(|c| c.runtime_options.preserve_symlinks_main_enabled()); + if preserve_main && entry_path.is_symlink && !entry_path.pretty.is_empty() { + Some(entry_path.pretty) + } else { + Some(entry_path.text) + } + } None => { *error_message = BunString::static_(b"Worker entry point is missing"); None diff --git a/src/options_types/context.rs b/src/options_types/context.rs index ad3550028311..34c75b100557 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -594,6 +594,16 @@ pub struct HeapProf { pub dir: Box<[u8]>, } +impl RuntimeOptions { + /// `--preserve-symlinks-main` or `NODE_PRESERVE_SYMLINKS_MAIN=1`. + pub fn preserve_symlinks_main_enabled(&self) -> bool { + self.preserve_symlinks_main + || bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN + .get() + .unwrap_or(false) + } +} + impl Default for RuntimeOptions { // See `ContextData::default` — folded into the single startup call site. #[inline(always)] diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index b3c577215aee..538cd9604430 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1661,7 +1661,11 @@ impl<'a> Resolver<'a> { module_type_from_ext(name.ext).unwrap_or(options::ModuleType::Unknown); } - if let Some(entries) = dir.get_entries_ref(self.generation) { + // With --preserve-symlinks, the link path stays the module's + // identity (matching Node), so skip resolving it to the target. + if !self.opts.preserve_symlinks + && let Some(entries) = dir.get_entries_ref(self.generation) + { if let Some(query) = entries.get(name.filename) { // SAFETY: entries_mutex held; rfs points at the process-global RealFS. let symlink_path = diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 2f58e6f9fdbf..31334e154bd2 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -2550,18 +2550,11 @@ impl RunCommand { bstr::BStr::new(target_name), bstr::BStr::new(fs_top_level_dir), ); - // Temporarily honor `--preserve-symlinks-main` / NODE_PRESERVE_SYMLINKS_MAIN - // for this one resolve. + let preserve_symlinks_main = ctx.runtime_options.preserve_symlinks_main_enabled(); let resolution: ::core::result::Result = { - let saved_preserve = this_transpiler.resolver.opts.preserve_symlinks; - this_transpiler.resolver.opts.preserve_symlinks = - ctx.runtime_options.preserve_symlinks_main - || bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN - .get() - .unwrap_or(false); // SAFETY: `Transpiler::init` always sets `fs`; resolver-cache lifetime. let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir }; - let resolved = match this_transpiler.resolver.resolve( + match this_transpiler.resolver.resolve( top_level_dir, target_name, bun_ast::ImportKind::EntryPointRun, @@ -2576,9 +2569,7 @@ impl RunCommand { bun_ast::ImportKind::EntryPointRun, ) } - }; - this_transpiler.resolver.opts.preserve_symlinks = saved_preserve; - resolved + } }; // (path, loader) — captured if the resolve hit a real file whose // loader Bun cannot execute (e.g. `.css`); used by the `log_errors` @@ -2597,9 +2588,29 @@ impl RunCommand { .unwrap_or(Loader::Tsx); if loader.can_be_run_by_bun() || loader == Loader::Html || loader == Loader::Md { bun_core::scoped_log!(RUN_LOG, "Resolved to: `{}`", bstr::BStr::new(path.text)); - // borrowck — `boot_and_handle_error` takes - // `&mut ctx`; copy `path.text` out of the resolver borrow. - let text: Box<[u8]> = path.text.to_vec().into_boxed_slice(); + // Node applies --preserve-symlinks-main / NODE_PRESERVE_SYMLINKS_MAIN + // (not --preserve-symlinks) to the entry point. Adjust the + // resolved spelling here instead of flipping + // `opts.preserve_symlinks` for one resolve: that option + // shapes the process-lifetime DirInfo cache, which is + // keyed by path only. + // (Also copies `path.text` out of the resolver borrow for + // the `&mut ctx` call below.) + let text: Box<[u8]> = + if preserve_symlinks_main && path.is_symlink && !path.pretty.is_empty() { + // The resolver realpathed the entry; `set_realpath` + // kept the link spelling in `pretty`. + path.pretty.to_vec().into_boxed_slice() + } else if !preserve_symlinks_main + && this_transpiler.resolver.opts.preserve_symlinks + { + // The preserve-mode resolver kept the link path, + // but the entry must be realpathed. + bun_sys::realpath_by_open(path.text) + .unwrap_or_else(|| path.text.to_vec().into_boxed_slice()) + } else { + path.text.to_vec().into_boxed_slice() + }; return Ok(Self::boot_and_handle_error(ctx, &text, Some(loader))); } else { bun_core::scoped_log!( @@ -2861,8 +2872,29 @@ impl RunCommand { }); // Re-derive the canonical absolute path from the open fd (resolves - // symlinks). - let absolute_script_path: Box<[u8]> = { + // symlinks). With --preserve-symlinks-main / NODE_PRESERVE_SYMLINKS_MAIN, + // keep the link path as the entry's identity instead (matching Node). + let preserve_symlinks_main = ctx.runtime_options.preserve_symlinks_main_enabled(); + let absolute_script_path: Box<[u8]> = if preserve_symlinks_main { + let mut cwd_buf = PathBuffer::uninit(); + let Ok(cwd) = bun_core::getcwd(&mut cwd_buf) else { + let _ = bun_sys::close(fd); + return false; + }; + let cwd_len = cwd.as_bytes().len(); + cwd_buf[cwd_len] = paths::SEP; + let mut abs_buf = PathBuffer::uninit(); + let joined = paths::resolve_path::join_abs_string_buf::( + &cwd_buf[..cwd_len + 1], + &mut abs_buf.0, + &[&script_name_buf[..open_len]], + ); + if joined.is_empty() { + let _ = bun_sys::close(fd); + return false; + } + joined.to_vec().into_boxed_slice() + } else { let resolved = match bun_sys::get_fd_path(fd, &mut script_name_buf) { Ok(p) => p, Err(_) => { diff --git a/src/sys/lib.rs b/src/sys/lib.rs index d9b75097803f..8af3c207ce17 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -7684,6 +7684,25 @@ fn get_fd_path_freebsd_linuxulator<'a>( Ok(&mut out.0[..len]) } +/// Canonicalize `path` by opening it read-only and deriving the fd's +/// absolute path via [`get_fd_path`]. `None` when the path is too long or +/// cannot be opened. +pub fn realpath_by_open(path: &[u8]) -> Option> { + let mut buf = bun_paths::path_buffer_pool::get(); + if path.len() >= buf.len() { + return None; + } + buf[..path.len()].copy_from_slice(path); + buf[path.len()] = 0; + // SAFETY: `buf[path.len()] == 0` written above. + let z = ZStr::from_buf(&buf[..], path.len()); + let fd = open(z, O::RDONLY, 0).ok()?; + let mut out = bun_paths::path_buffer_pool::get(); + let resolved = get_fd_path(fd, &mut out); + let _ = close(fd); + resolved.ok().map(|p| p.to_vec().into_boxed_slice()) +} + /// fd → absolute path. Linux: readlink `/proc/self/fd/N`; /// macOS: `fcntl(F_GETPATH)`; Windows: `GetFinalPathNameByHandle`. pub fn get_fd_path<'a>(fd: Fd, out: &'a mut bun_paths::PathBuffer) -> Maybe<&'a mut [u8]> { diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 43c644bfaec8..9f5f49e09b66 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -1101,3 +1101,171 @@ it.skipIf(isWindows)("reports a resolution error for an absolute specifier of th expect(stdout).toBe("ResolveMessage ERR_MODULE_NOT_FOUND\n"); expect(exitCode).toBe(0); }); + +describe("--preserve-symlinks", () => { + // A shared library outside the app, symlinked into it. With the flag, the + // link path stays the module's identity (as in Node), so specifiers inside + // the library resolve from the app. + function makeTree() { + const dir = tempDirWithFiles("preserve-symlinks", { + "package.json": `{ "name": "root", "version": "1.0.0" }`, + "shared/lib/relative.mjs": `import dep from "../gen/dep.mjs";\nexport default dep;\n`, + "shared/lib/relative.cjs": `module.exports = require("../gen/dep.cjs");\n`, + "shared/lib/bare.mjs": `import pkg from "somepkg";\nexport default pkg;\n`, + "shared/lib/where.mjs": `export default import.meta.url;\n`, + "shared/main.mjs": `import v from "./gen/dep.mjs";\nconsole.log(v);\n`, + "shared/mainjs.js": `import v from "./gen/dep.mjs";\nconsole.log(v);\n`, + "shared/worker.mjs": `import v from "./gen/dep.mjs";\nconsole.log(v);\n`, + "app/gen/dep.mjs": `export default "RESOLVED-FROM-APP-GEN";\n`, + "app/gen/dep.cjs": `module.exports = "RESOLVED-FROM-APP-GEN";\n`, + "app/node_modules/somepkg/package.json": `{ "name": "somepkg", "version": "1.0.0", "type": "module", "main": "index.mjs" }`, + "app/node_modules/somepkg/index.mjs": `export default "RESOLVED-FROM-APP-NODE-MODULES";\n`, + "app/main-relative.mjs": `import v from "./lib/relative.mjs";\nconsole.log(v);\n`, + "app/main-relative.cjs": `console.log(require("./lib/relative.cjs"));\n`, + "app/main-bare.mjs": `import v from "./lib/bare.mjs";\nconsole.log(v);\n`, + "app/main-where.mjs": `import url from "./lib/where.mjs";\nconsole.log(url);\n`, + "app/main-worker.mjs": `import { Worker } from "worker_threads";\nconst w = new Worker("./worker-sym.mjs");\nw.on("error", e => { console.error(e.message); process.exit(1); });\nw.on("exit", c => process.exit(c));\n`, + }); + mkdirSync(join(dir, "app", "lib")); + for (const f of ["relative.mjs", "relative.cjs", "bare.mjs", "where.mjs"]) { + symlinkSync(join(dir, "shared", "lib", f), join(dir, "app", "lib", f), "file"); + } + symlinkSync(join(dir, "shared", "main.mjs"), join(dir, "app", "main-sym.mjs"), "file"); + symlinkSync(join(dir, "shared", "mainjs.js"), join(dir, "app", "mainsym.js"), "file"); + symlinkSync(join(dir, "shared", "worker.mjs"), join(dir, "app", "worker-sym.mjs"), "file"); + return dir; + } + + async function run(cwd: string, args: string[], env: Record = bunEnv) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + it.concurrent("relative specifier in a symlinked ESM module resolves from the link's directory", async () => { + const dir = makeTree(); + const { stdout, stderr, exitCode } = await run(join(dir, "app"), ["--preserve-symlinks", "main-relative.mjs"]); + expect(stderr).toBe(""); + expect(stdout).toBe("RESOLVED-FROM-APP-GEN\n"); + expect(exitCode).toBe(0); + }); + + it.concurrent("relative specifier in a symlinked CJS module resolves from the link's directory", async () => { + const dir = makeTree(); + const { stdout, stderr, exitCode } = await run(join(dir, "app"), ["--preserve-symlinks", "main-relative.cjs"]); + expect(stderr).toBe(""); + expect(stdout).toBe("RESOLVED-FROM-APP-GEN\n"); + expect(exitCode).toBe(0); + }); + + it.concurrent("bare specifier in a symlinked module resolves against the consumer's node_modules", async () => { + const dir = makeTree(); + const { stdout, stderr, exitCode } = await run(join(dir, "app"), [ + "--preserve-symlinks", + "--no-install", + "main-bare.mjs", + ]); + expect(stderr).toBe(""); + expect(stdout).toBe("RESOLVED-FROM-APP-NODE-MODULES\n"); + expect(exitCode).toBe(0); + }); + + it.concurrent("import.meta.url keeps the link path with the flag and the real path without it", async () => { + const dir = makeTree(); + const withFlag = await run(join(dir, "app"), ["--preserve-symlinks", "main-where.mjs"]); + expect(withFlag.stdout.trim()).toEndWith("/app/lib/where.mjs"); + expect(withFlag.exitCode).toBe(0); + + const withoutFlag = await run(join(dir, "app"), ["main-where.mjs"]); + expect(withoutFlag.stdout.trim()).toEndWith("/shared/lib/where.mjs"); + expect(withoutFlag.exitCode).toBe(0); + }); + + it.concurrent("NODE_PRESERVE_SYMLINKS=1 behaves like the flag", async () => { + const dir = makeTree(); + const { stdout, stderr, exitCode } = await run(join(dir, "app"), ["main-relative.mjs"], { + ...bunEnv, + NODE_PRESERVE_SYMLINKS: "1", + }); + expect(stderr).toBe(""); + expect(stdout).toBe("RESOLVED-FROM-APP-GEN\n"); + expect(exitCode).toBe(0); + }); + + it.concurrent("--preserve-symlinks-main keeps a symlinked entry point's link path", async () => { + const dir = makeTree(); + const withMain = await run(join(dir, "app"), ["--preserve-symlinks-main", "main-sym.mjs"]); + expect(withMain.stderr).toBe(""); + expect(withMain.stdout).toBe("RESOLVED-FROM-APP-GEN\n"); + expect(withMain.exitCode).toBe(0); + + // `bun run` with an extensionless target goes through the module + // resolution fallback rather than the direct-file fast path. + const fallback = await run(join(dir, "app"), ["run", "--preserve-symlinks-main", "./mainsym"]); + expect(fallback.stderr).toBe(""); + expect(fallback.stdout).toBe("RESOLVED-FROM-APP-GEN\n"); + expect(fallback.exitCode).toBe(0); + + // Same fallback path without -main: the entry is realpathed even though + // --preserve-symlinks kept the resolver in preserve mode. + const fallbackWithoutMain = await run(join(dir, "app"), ["run", "--preserve-symlinks", "./mainsym"]); + expect(fallbackWithoutMain.stderr).toContain("Cannot find module './gen/dep.mjs'"); + expect(fallbackWithoutMain.stdout).toBe(""); + expect(fallbackWithoutMain.exitCode).not.toBe(0); + + // Without -main the entry is still realpathed (Node behavior), so its + // relative import resolves from shared/ and fails. + const withoutMain = await run(join(dir, "app"), ["--preserve-symlinks", "main-sym.mjs"]); + expect(withoutMain.stderr).toContain("Cannot find module './gen/dep.mjs'"); + expect(withoutMain.exitCode).not.toBe(0); + }); + + it.concurrent("--preserve-symlinks-main applies to a Worker's entry point", async () => { + const dir = makeTree(); + const { stdout, stderr, exitCode } = await run(join(dir, "app"), ["--preserve-symlinks-main", "main-worker.mjs"]); + expect(stderr).toBe(""); + expect(stdout).toBe("RESOLVED-FROM-APP-GEN\n"); + expect(exitCode).toBe(0); + }); + + it.concurrent( + "-main only: entry under a symlinked directory keeps its link path, other modules are realpathed", + async () => { + // The entry's resolve must not leak preserve-mode entries into the shared + // dir cache: foo.cjs below has to be realpathed even though the entry is + // not. + const dir = tempDirWithFiles("preserve-symlinks-main-dir", { + "real/app/bin/entry.cjs": `const foo = require("../lib/foo.cjs");\nconsole.log(__filename);\nconsole.log(foo);\n`, + "real/app/lib/foo.cjs": `module.exports = __filename;\n`, + }); + symlinkSync(join(dir, "real"), join(dir, "opt"), "dir"); + const { stdout, stderr, exitCode } = await run(dir, [ + "--preserve-symlinks-main", + join(dir, "opt", "app", "bin", "entry.cjs"), + ]); + expect(stderr).toBe(""); + const [entryLine, fooLine] = stdout.replaceAll("\\", "/").trim().split("\n"); + expect(entryLine).toBe(join(dir, "opt", "app", "bin", "entry.cjs").replaceAll("\\", "/")); + expect(fooLine).toBe(join(realpathSync(dir), "real", "app", "lib", "foo.cjs").replaceAll("\\", "/")); + expect(exitCode).toBe(0); + }, + ); + + it.concurrent("bun build --preserve-symlinks resolves through file symlinks", async () => { + const dir = makeTree(); + const { stdout, stderr, exitCode } = await run(join(dir, "app"), [ + "build", + "main-relative.mjs", + "--preserve-symlinks", + ]); + expect(stderr).toBe(""); + expect(stdout).toContain("RESOLVED-FROM-APP-GEN"); + expect(exitCode).toBe(0); + }); +});