From 0bf2b221ca409942bd8533ab6b71032ecdb8b3ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:47:40 +0000 Subject: [PATCH 01/10] paths: resolve a non-absolute join base against the working directory --- src/paths/resolve_path.rs | 189 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 6 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 0959c4b62d6b..cd786cdff018 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1713,8 +1713,45 @@ pub fn join_abs_string_buf_z<'a, P: PlatformT>( unsafe { ZStr::from_raw(r.as_ptr(), r.len()) } } +/// Directory that a non-absolute join base resolves against: the top-level +/// directory once one has been recorded, otherwise the live working directory. +/// Returns an empty slice when neither is available. +fn working_dir(buf: &mut PathBuffer) -> &[u8] { + let top_level_dir = bun_core::top_level_dir(); + if crate::is_absolute(top_level_dir) { + return top_level_dir; + } + match bun_core::getcwd(buf) { + Ok(cwd) => cwd.as_bytes(), + Err(_) => b"", + } +} + +/// Joins `parts`, none of which is absolute, onto the working directory. This +/// is the fallback for a join whose base is not absolute, such as an empty or +/// relative `$HOME` or `$TMPDIR`: `("rel", ["x"])` resolves to `/rel/x`. +/// `parts` must not be empty, so the result always lands in `buf`. +#[cold] +#[inline(never)] +fn join_onto_working_dir<'a, const IS_SENTINEL: bool, P: PlatformT>( + buf: &'a mut [u8], + parts: &[&[u8]], +) -> &'a [u8] { + debug_assert!(!parts.is_empty()); + let mut cwd_buf = crate::path_buffer_pool::get(); + let cwd = working_dir(&mut cwd_buf); + // `/` is absolute under every platform's rule, so the nested join cannot + // end up back here. + let cwd: &[u8] = if P::P.is_absolute(cwd) { cwd } else { b"/" }; + let len = _join_abs_string_buf::(cwd, &mut *buf, parts).len(); + &buf[..len] +} + // We always return `&[u8]`; when `IS_SENTINEL` a NUL is written // at `result.len()` and callers (e.g. `join_abs_string_buf_z`) re-wrap as `ZStr`. +// +// `_cwd` should be absolute. When it is not and no part is absolute either, +// the result is resolved against the working directory (`join_onto_working_dir`). fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( _cwd: &'a [u8], buf: &'a mut [u8], @@ -1802,21 +1839,26 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( out += part.len(); } + let Some(i) = P::P.leading_separator_index::(&temp_buf[0..out]) else { + // Nothing anchors the path: `cwd` is empty or relative and no part is + // absolute. Inventing a root here would take the first byte of the + // path for the separator (`("rel", ["x"])` became `/el/x`), so resolve + // the relative path against the working directory instead. + let relative: &[u8] = &temp_buf[0..out]; + return join_onto_working_dir::(buf, &[relative]); + }; + // reshaped for borrowck — stash leading separator into a local // [u8; 8] (max len: NT prefix `\\?\` = 4) so we don't hold a borrow into // temp_buf across the normalize call below. let mut leading_buf = [0u8; 8]; - let leading_len: usize = if let Some(i) = P::P.leading_separator_index::(&temp_buf[0..out]) - { + let leading_len: usize = { let outdir = &mut temp_buf[0..i + 1]; if P::P == Platform::Loose { slashes_to_posix_in_place(outdir); } leading_buf[..i + 1].copy_from_slice(&temp_buf[0..i + 1]); i + 1 - } else { - leading_buf[0] = b'/'; - 1 }; // Copy leading separator into buf (order-independent with normalize, // which writes into buf[leading_len..]). @@ -1839,7 +1881,15 @@ fn join_abs_string_buf_windows<'a, const IS_SENTINEL: bool>( buf: &'a mut [u8], parts: &[&[u8]], ) -> &'a [u8] { - debug_assert!(crate::is_absolute_windows(cwd)); + if !crate::is_absolute_windows(cwd) { + // Same fallback as the POSIX arm: an empty or relative `cwd` is the + // first segment of a path resolved against the working directory. An + // absolute part still wins, as the nested join sees it too. + let mut all_parts: Vec<&[u8]> = Vec::with_capacity(parts.len() + 1); + all_parts.push(cwd); + all_parts.extend_from_slice(parts); + return join_onto_working_dir::(buf, &all_parts); + } if parts.is_empty() { if IS_SENTINEL { @@ -2634,6 +2684,133 @@ mod tests { assert_eq!(out, b"/work/sub"); } + /// `/` is absolute under the POSIX rule and the Windows rule alike, so one + /// recorded working directory serves the tests of both arms. + fn record_working_dir() { + bun_core::set_top_level_dir(b"/work"); + } + + #[test] + fn join_abs_resolves_a_relative_base_against_the_working_dir() { + record_working_dir(); + assert_eq!( + join_abs_string::(b"rel", &[b"x"]), + b"/work/rel/x" + ); + assert_eq!( + join_abs_string::(b"reltmp", &[b".bun-0.node"]), + b"/work/reltmp/.bun-0.node" + ); + assert_eq!( + join_abs_string::(b".", &[b"./.npmrc"]), + b"/work/.npmrc" + ); + assert_eq!( + join_abs_string::(b"a/..", &[b"b"]), + b"/work/b" + ); + assert_eq!( + join_abs_string::(b"rel", &[b"x"]), + b"/work/rel/x" + ); + } + + #[test] + fn join_abs_resolves_an_empty_base_against_the_working_dir() { + record_working_dir(); + assert_eq!( + join_abs_string::(b"", &[b"install", b"global"]), + b"/work/install/global" + ); + assert_eq!( + join_abs_string::(b"", &[b".bunfig.toml"]), + b"/work/.bunfig.toml" + ); + assert_eq!(join_abs_string::(b"", &[b""]), b"/work"); + } + + #[test] + fn join_abs_with_a_relative_base_still_lets_an_absolute_part_win() { + record_working_dir(); + assert_eq!( + join_abs_string::(b"rel", &[b"/abs", b"y"]), + b"/abs/y" + ); + assert_eq!( + join_abs_string::(b"", &[b"x", b"/abs"]), + b"/abs" + ); + } + + #[test] + fn join_abs_z_with_a_relative_base_is_nul_terminated_in_the_buffer() { + record_working_dir(); + let mut buf = [0xAAu8; 64]; + let out = join_abs_string_buf_z::(b"rel", &mut buf, &[b"x"]); + assert_eq!(out.as_bytes(), b"/work/rel/x"); + assert_eq!(buf[b"/work/rel/x".len()], 0); + + let out = join_abs_string_z::(b"", &[b"install", b"cache"]); + assert_eq!(out.as_bytes(), b"/work/install/cache"); + } + + #[test] + fn join_abs_checked_and_spill_resolve_a_relative_base_too() { + record_working_dir(); + let mut buf = [0u8; 64]; + assert_eq!( + join_abs_string_buf_checked::(b"rel", &mut buf, &[b"x"]), + Some(&b"/work/rel/x"[..]) + ); + let mut spill = Vec::new(); + assert_eq!( + join_abs_string_spill::(b"rel", &mut spill, &[b"x"]), + b"/work/rel/x" + ); + } + + #[test] + fn join_abs_windows_resolves_a_relative_or_empty_base_against_the_working_dir() { + record_working_dir(); + assert_eq!( + join_abs_string::(b"rel", &[b"bin"]), + b"\\work\\rel\\bin" + ); + assert_eq!( + join_abs_string::(b"", &[b"install", b"global"]), + b"\\work\\install\\global" + ); + assert_eq!( + join_abs_string::(b"rel", &[b"C:\\abs", b"y"]), + b"C:\\abs\\y" + ); + let mut buf = [0xAAu8; 64]; + let out = join_abs_string_buf_z::(b"", &mut buf, &[b"bin"]); + assert_eq!(out.as_bytes(), b"\\work\\bin"); + assert_eq!(buf[b"\\work\\bin".len()], 0); + assert_eq!( + join_abs_string::(b"rel", &[b"bin"]), + b"\\\\?\\\\work\\rel\\bin" + ); + } + + #[test] + fn join_abs_with_an_absolute_base_does_not_consult_the_working_dir() { + record_working_dir(); + assert_eq!( + join_abs_string::(b"/home/u", &[b".bunfig.toml"]), + b"/home/u/.bunfig.toml" + ); + assert_eq!( + join_abs_string::(b"C:/home/u", &[b"x"]), + b"C:/home/u/x" + ); + assert_eq!( + join_abs_string::(b"C:\\home\\u", &[b"bin"]), + b"C:\\home\\u\\bin" + ); + } + #[test] fn normalize_string_spill_accounts_for_outputs_that_grow_by_one_byte() { // A bare UNC volume exactly as long as the thread-local buffer From 87a0746e6339b86d030bf18a63d2bbb7bf17daaa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:54:05 +0000 Subject: [PATCH 02/10] resolver: resolve a relative temp dir against the working directory once --- src/paths/resolve_path.rs | 11 ++-- src/resolver/lib.rs | 109 +++++++++++++++++++++----------------- 2 files changed, 68 insertions(+), 52 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index cd786cdff018..2a672644810d 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1713,10 +1713,13 @@ pub fn join_abs_string_buf_z<'a, P: PlatformT>( unsafe { ZStr::from_raw(r.as_ptr(), r.len()) } } -/// Directory that a non-absolute join base resolves against: the top-level -/// directory once one has been recorded, otherwise the live working directory. -/// Returns an empty slice when neither is available. -fn working_dir(buf: &mut PathBuffer) -> &[u8] { +/// Directory that a non-absolute `join_abs*` base resolves against: the +/// top-level directory once one has been recorded, otherwise the live working +/// directory. Returns an empty slice when neither is available (the join then +/// anchors the path at the root). Callers that store an environment-supplied +/// directory for later joins resolve it against this once, so that every +/// consumer names the same directory even if the process changes directory. +pub fn working_dir(buf: &mut PathBuffer) -> &[u8] { let top_level_dir = bun_core::top_level_dir(); if crate::is_absolute(top_level_dir) { return top_level_dir; diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 9e5c9c02fef2..aee46f461b7c 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -62,6 +62,7 @@ pub use standalone_module_graph::StandaloneModuleGraph; /// in-tree types (`FileSystem`, `RealFS`, `Entry`, ...). pub mod fs { use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + use std::borrow::Cow; use std::io::Write as _; use bun_core::ZStr; @@ -1643,7 +1644,7 @@ pub mod fs { Some(unsafe { &mut *result_ptr }) } - fn platform_temp_dir_compute() -> &'static [u8] { + fn platform_temp_dir_compute() -> Cow<'static, [u8]> { use bun_core::env_var; // Try TMPDIR, TMP, and TEMP in that order, matching Node.js. // https://github.com/nodejs/node/blob/e172be269890702bf2ad06252f2f152e7604d76c/src/node_credentials.cc#L132 @@ -1653,75 +1654,87 @@ pub mod fs { .or_else(|| env_var::TEMP.get_not_empty()) { if dir.len() > 1 && dir[dir.len() - 1] == bun_paths::SEP { - return &dir[0..dir.len() - 1]; + return Self::absolute_temp_dir(Cow::Borrowed(&dir[0..dir.len() - 1])); } - return dir; + return Self::absolute_temp_dir(Cow::Borrowed(dir)); } #[cfg(target_os = "windows")] { // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-gettemppathw#remarks - // The computed path borrows env-var storage joined with a literal, - // so it must own its buffer. This runs once for the process via - // `bun_core::Once` in `platform_temp_dir()`; the `OnceLock` here is - // the allowed process-lifetime singleton (PORTING.md §Forbidden - // exception), not a per-call leak. - static OWNED: std::sync::OnceLock> = std::sync::OnceLock::new(); - return OWNED - .get_or_init(|| { - if let Some(windir) = - env_var::SYSTEMROOT.get().or_else(|| env_var::WINDIR.get()) - { - let mut out = - bun_core::strings::without_trailing_slash(windir).to_vec(); - out.extend_from_slice(b"\\Temp"); - return out; - } - if let Some(profile) = env_var::HOME.get() { - let mut buf = bun_paths::PathBuffer::uninit(); - let parts: [&[u8]; 1] = [b"AppData\\Local\\Temp"]; - let out = bun_paths::resolve_path::join_abs_string_buf::< - bun_paths::resolve_path::platform::Loose, - >(profile, &mut buf[..], &parts); - return out.to_vec(); - } - let mut tmp_buf = bun_paths::PathBuffer::uninit(); - let cwd = match bun_sys::getcwd(&mut tmp_buf[..]) { - Ok(len) => &tmp_buf[..len], - Err(_) => panic!("Failed to get cwd for platformTempDir"), - }; - let root = bun_paths::resolve_path::windows_filesystem_root(cwd); - let mut out = bun_core::strings::without_trailing_slash(root).to_vec(); - out.extend_from_slice(b"\\Windows\\Temp"); - out - }) - .as_slice(); + if let Some(windir) = env_var::SYSTEMROOT + .get_not_empty() + .or_else(|| env_var::WINDIR.get_not_empty()) + { + let mut out = bun_core::strings::without_trailing_slash(windir).to_vec(); + out.extend_from_slice(b"\\Temp"); + return Self::absolute_temp_dir(Cow::Owned(out)); + } + if let Some(profile) = env_var::HOME.get_not_empty() { + let mut cwd_buf = bun_paths::path_buffer_pool::get(); + let cwd = bun_paths::resolve_path::working_dir(&mut cwd_buf); + let parts: [&[u8]; 2] = [profile, b"AppData\\Local\\Temp"]; + let out = bun_paths::resolve_path::join_abs_string::< + bun_paths::resolve_path::platform::Loose, + >(cwd, &parts); + return Cow::Owned(out.to_vec()); + } + let mut tmp_buf = bun_paths::PathBuffer::uninit(); + let cwd = match bun_sys::getcwd(&mut tmp_buf[..]) { + Ok(len) => &tmp_buf[..len], + Err(_) => panic!("Failed to get cwd for platformTempDir"), + }; + let root = bun_paths::resolve_path::windows_filesystem_root(cwd); + let mut out = bun_core::strings::without_trailing_slash(root).to_vec(); + out.extend_from_slice(b"\\Windows\\Temp"); + return Cow::Owned(out); } #[cfg(target_os = "macos")] { - return b"/private/tmp"; + return Cow::Borrowed(b"/private/tmp"); } #[cfg(target_os = "android")] { - return b"/data/local/tmp"; + return Cow::Borrowed(b"/data/local/tmp"); } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "android")))] { - b"/tmp" + Cow::Borrowed(b"/tmp") + } + } + + /// A directory taken from the environment can be relative + /// (`TMPDIR=reltmp`). The directory handle is opened from this string + /// relative to the cwd, while the paths handed out for the files inside + /// it are joined onto this string, so it is resolved once here and both + /// name the same directory. + fn absolute_temp_dir(dir: Cow<'static, [u8]>) -> Cow<'static, [u8]> { + if bun_paths::is_absolute(&dir) { + return dir; } + let mut cwd_buf = bun_paths::path_buffer_pool::get(); + let cwd = bun_paths::resolve_path::working_dir(&mut cwd_buf); + let parts: [&[u8]; 1] = [&dir]; + Cow::Owned( + bun_paths::resolve_path::join_abs_string::(cwd, &parts) + .to_vec(), + ) } - /// Platform temp directory, computed once per process. + /// Platform temp directory, computed once per process. Always absolute. pub fn platform_temp_dir() -> &'static [u8] { - static ONCE: bun_core::Once<&'static [u8]> = bun_core::Once::new(); - ONCE.call(Self::platform_temp_dir_compute) + static ONCE: bun_core::Once> = bun_core::Once::new(); + ONCE.get_or_init(Self::platform_temp_dir_compute) } - /// Non-empty `BUN_TMPDIR`, falling back to `platform_temp_dir`. + /// Non-empty `BUN_TMPDIR`, falling back to `platform_temp_dir`. Computed + /// once per process. Always absolute. pub fn tmpdir_path() -> &'static [u8] { - bun_core::env_var::BUN_TMPDIR - .get_not_empty() - .unwrap_or_else(Self::platform_temp_dir) + static ONCE: bun_core::Once> = bun_core::Once::new(); + ONCE.get_or_init(|| match bun_core::env_var::BUN_TMPDIR.get_not_empty() { + Some(dir) => Self::absolute_temp_dir(Cow::Borrowed(dir)), + None => Cow::Borrowed(Self::platform_temp_dir()), + }) } pub fn get_default_temp_dir() -> &'static [u8] { From bbf523c00725ff811f03caba8a363dd714df61ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:34:00 +0000 Subject: [PATCH 03/10] Resolve environment-supplied directories against the working directory instead of the root --- src/bunfig/arguments.rs | 57 +++++------ src/install/PackageManager.rs | 18 +++- .../PackageManagerDirectories.rs | 44 ++------- .../PackageManager/PackageManagerOptions.rs | 97 +++++++++---------- src/install/repository.rs | 9 +- src/options_types/compile_target.rs | 9 +- src/options_types/install_cache_dir.rs | 48 +++++++++ src/options_types/lib.rs | 1 + src/runtime/api/cron.rs | 4 +- .../cli/install_completions_command.rs | 6 +- src/runtime/cli/pm_diff_command.rs | 4 +- src/runtime/cli/upgrade_command.rs | 2 +- src/runtime/ffi/ffi_body.rs | 7 +- src/runtime/webview/ChromeProcess.rs | 18 +++- src/standalone_graph/StandaloneModuleGraph.rs | 8 +- src/sys/lib.rs | 16 --- 16 files changed, 189 insertions(+), 159 deletions(-) create mode 100644 src/options_types/install_cache_dir.rs diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index d0a388d3ef7c..518d63c89dd4 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -8,7 +8,7 @@ use bun_bundler::options; use bun_core::ZStr; use bun_core::{self, Global, Output, env_var}; use bun_options_types::command_tag::{ALWAYS_LOADS_CONFIG, Tag as CommandTag}; -use bun_options_types::context::Context; +use bun_options_types::context::{Context, ContextData}; use bun_paths::PathBuffer; use bun_paths::resolve_path::{self, platform}; use bun_standalone_graph::StandaloneModuleGraph::StandaloneModuleGraph; @@ -17,22 +17,26 @@ use crate::bunfig::Bunfig; // ─── bunfig loading ────────────────────────────────────────────────────────── -fn get_home_config_path(buf: &mut PathBuffer) -> Option<&ZStr> { - let paths: [&[u8]; 1] = [b".bunfig.toml"]; - - if let Some(data_dir) = env_var::XDG_CONFIG_HOME.get() { - return Some(resolve_path::join_abs_string_buf_z::( - data_dir, &mut **buf, &paths, - )); - } +/// `$XDG_CONFIG_HOME/.bunfig.toml`, else `$HOME/.bunfig.toml`. An empty +/// variable is ignored. A relative one is resolved against `cwd`. +fn get_home_config_path<'b>(cwd: &[u8], buf: &'b mut PathBuffer) -> Option<&'b ZStr> { + let config_dir = env_var::XDG_CONFIG_HOME + .get_not_empty() + .or_else(|| env_var::HOME.get_not_empty())?; + let parts: [&[u8]; 2] = [config_dir, b".bunfig.toml"]; + let len = resolve_path::join_abs_string_buf_z::(cwd, &mut **buf, &parts).len(); + Some(ZStr::from_buf(&buf[..], len)) +} - if let Some(home_dir) = env_var::HOME.get() { - return Some(resolve_path::join_abs_string_buf_z::( - home_dir, &mut **buf, &paths, - )); +/// `Arguments::parse` records the cwd (after `--cwd`) before any config is +/// loaded. Callers that do not go through it get the live cwd. +fn absolute_working_dir(ctx: &mut ContextData) -> Option<&[u8]> { + if ctx.args.absolute_working_dir.is_none() { + let mut buf = PathBuffer::uninit(); + let len = bun_sys::getcwd(&mut *buf).ok()?; + ctx.args.absolute_working_dir = Some(Box::<[u8]>::from(&buf[..len])); } - - None + ctx.args.absolute_working_dir.as_deref() } fn load_bunfig( @@ -87,7 +91,9 @@ fn load_global_bunfig(cmd: CommandTag, ctx: Context<'_>) -> Result<(), crate::Er ctx.has_loaded_global_config = true; let mut config_buf = PathBuffer::uninit(); - if let Some(path) = get_home_config_path(&mut config_buf) { + if let Some(path) = + absolute_working_dir(ctx).and_then(|cwd| get_home_config_path(cwd, &mut config_buf)) + { load_bunfig(cmd, true, path, ctx)?; } Ok(()) @@ -156,7 +162,9 @@ pub fn load_config( if !ctx.has_loaded_global_config { ctx.has_loaded_global_config = true; - if let Some(path) = get_home_config_path(&mut config_buf) { + if let Some(path) = + absolute_working_dir(ctx).and_then(|cwd| get_home_config_path(cwd, &mut config_buf)) + { if let Err(err) = load_config_path(cmd, true, path, ctx) { report_bunfig_load_failure(ctx.log, err); } @@ -193,22 +201,15 @@ pub fn load_config( config_buf[config_path_.len()] = 0; config_path_len = config_path_.len(); } else { - if ctx.args.absolute_working_dir.is_none() { - let mut secondbuf = PathBuffer::uninit(); - let cwd_len = match bun_sys::getcwd(&mut *secondbuf) { - Ok(n) => n, - Err(_) => return Ok(()), - }; - ctx.args.absolute_working_dir = Some(Box::<[u8]>::from(&secondbuf[..cwd_len])); - } - + let Some(awd) = absolute_working_dir(ctx) else { + return Ok(()); + }; // Reshaped for borrowck: `join_abs_string_buf` ties the - // returned slice's lifetime to both `cwd` (borrowed from `ctx.args`) + // returned slice's lifetime to both `awd` (borrowed from `ctx.args`) // and `config_buf`. We only need the length to NUL-terminate and // re-wrap, so capture `joined.len()` and drop the `ctx` borrow before // the `&mut ctx` call below. config_path_len = { - let awd: &[u8] = ctx.args.absolute_working_dir.as_deref().unwrap(); let parts: [&[u8]; 2] = [awd, config_path_]; let joined = resolve_path::join_abs_string_buf::(awd, &mut *config_buf, &parts); diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index bd0763f6f3f0..836e7fcb831d 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1921,22 +1921,32 @@ pub fn init( let npmrc_local = ZBox::from_bytes(b".npmrc"); let mut buf = PathBuffer::uninit(); - let parts = [b"./.npmrc" as &[u8]]; + // A relative `$XDG_CONFIG_HOME` or `$HOME` is resolved against the + // directory the command was started in. The process has already + // changed into the workspace root (or, for `-g`, the global directory). + let started_in: &[u8] = ctx + .args + .absolute_working_dir + .as_deref() + .unwrap_or(&original_cwd_clone); // npm reads `$HOME/.npmrc` and ignores XDG_CONFIG_HOME; keep // `$XDG_CONFIG_HOME/.npmrc` only when that file actually exists. let mut global_len: usize = 0; if let Some(xdg_dir) = bun_core::env_var::XDG_CONFIG_HOME.get_not_empty() { - let p = - resolve_path::join_abs_string_buf_z::(xdg_dir, &mut buf, &parts); + let parts: [&[u8]; 2] = [xdg_dir, b".npmrc"]; + let p = resolve_path::join_abs_string_buf_z::( + started_in, &mut buf, &parts, + ); if bun_sys::exists_z(p) { global_len = p.len(); } } if global_len == 0 { if let Some(home_dir) = bun_core::env_var::HOME.get_not_empty() { + let parts: [&[u8]; 2] = [home_dir, b".npmrc"]; global_len = resolve_path::join_abs_string_buf_z::( - home_dir, &mut buf, &parts, + started_in, &mut buf, &parts, ) .len(); } diff --git a/src/install/PackageManager/PackageManagerDirectories.rs b/src/install/PackageManager/PackageManagerDirectories.rs index 4861484c4b21..aa0e5a7ef50e 100644 --- a/src/install/PackageManager/PackageManagerDirectories.rs +++ b/src/install/PackageManager/PackageManagerDirectories.rs @@ -7,7 +7,7 @@ use crate::bun_fs::FileSystem; use crate::lockfile_real::package::PackageColumns; use crate::repository::Repository; use bun_core::ZStr; -use bun_core::{Global, Output, ZBox, env_var, fmt as bun_fmt}; +use bun_core::{Global, Output, ZBox, fmt as bun_fmt}; use bun_dotenv::Loader as DotEnvLoader; use bun_install::lockfile::{Format as LockfileFormat, LoadResult, Lockfile}; use bun_install::resolution::Tag as ResolutionTag; @@ -376,44 +376,12 @@ pub struct CacheDir { } pub fn fetch_cache_directory_path(env: &mut DotEnvLoader, options: Option<&Options>) -> CacheDir { - if let Some(dir) = env.get(b"BUN_INSTALL_CACHE_DIR") { - return CacheDir { - path: FileSystem::instance().abs(&[dir]).to_vec(), - }; - } - - if let Some(opts) = options { - if !opts.cache_directory.is_empty() { - return CacheDir { - path: FileSystem::instance().abs(&[opts.cache_directory]).to_vec(), - }; - } - } - - if let Some(dir) = env.get(b"BUN_INSTALL") { - let parts: [&[u8]; 3] = [dir, b"install/", b"cache/"]; - return CacheDir { - path: FileSystem::instance().abs(&parts).to_vec(), - }; - } - - if let Some(dir) = env_var::XDG_CACHE_HOME.get() { - let parts: [&[u8]; 4] = [dir, b".bun/", b"install/", b"cache/"]; - return CacheDir { - path: FileSystem::instance().abs(&parts).to_vec(), - }; - } - - if let Some(dir) = env_var::HOME.get() { - let parts: [&[u8]; 4] = [dir, b".bun/", b"install/", b"cache/"]; - return CacheDir { - path: FileSystem::instance().abs(&parts).to_vec(), - }; - } - - let fallback_parts: [&[u8]; 1] = [b"node_modules/.bun-cache"]; CacheDir { - path: FileSystem::instance().abs(&fallback_parts).to_vec(), + path: bun_options_types::install_cache_dir::fetch_cache_directory_path( + FileSystem::instance().top_level_dir(), + env, + options.map(|opts| opts.cache_directory), + ), } } diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 10504ff5a1cd..f74cc813dc14 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -298,12 +298,50 @@ pub use crate::config_version::ConfigVersion; pub use bun_install_types::DependencyGroup; pub use bun_install_types::NodeLinker::NodeLinker; +/// The directory that holds the global `install/global` and `bin` directories: +/// `$BUN_INSTALL`, else `$XDG_CACHE_HOME/.bun`, else `$HOME/.bun`. An empty +/// variable is ignored. +/// +/// A relative value is resolved against the working directory once. The global +/// directory is opened before a global install changes into it and the global +/// bin directory after, so resolving at each use would put them in different +/// places. +fn global_install_root() -> Option<&'static [u8]> { + static ROOT: bun_core::Once>> = bun_core::Once::new(); + ROOT.get_or_init(|| { + use bun_paths::{platform, resolve_path}; + + let mut cwd_buf = bun_paths::path_buffer_pool::get(); + let cwd = resolve_path::working_dir(&mut cwd_buf); + if let Some(dir) = env_var::BUN_INSTALL.get_not_empty() { + return Some(Box::from(resolve_path::join_abs_string::(cwd, &[dir]))); + } + let home_dir = env_var::XDG_CACHE_HOME + .get_not_empty() + .or_else(|| env_var::HOME.get_not_empty())?; + let parts: [&[u8]; 2] = [home_dir, b".bun"]; + Some(Box::from(resolve_path::join_abs_string::(cwd, &parts))) + }) + .as_deref() +} + +fn make_open_global_path(root: &[u8], parts: &[&[u8]]) -> crate::Result { + use bun_paths::{platform, resolve_path::join_abs_string_buf}; + use bun_sys::{Dir, OpenDirOptions}; + + let mut buf = PathBuffer::uninit(); + let path = join_abs_string_buf::(root, &mut buf.0, parts); + Dir::cwd() + .make_open_path(path, OpenDirOptions::default()) + .map(|d| d.into_raw()) + .map_err(Into::into) +} + // mkdir -p + open the dir. Callers store the raw `Fd` (`options.global_bin_dir: Fd`). pub fn open_global_dir(explicit_global_dir: &[u8]) -> crate::Result { - use bun_paths::{platform, resolve_path::join_abs_string_buf}; use bun_sys::{Dir, OpenDirOptions}; - if let Some(home_dir) = env_var::BUN_INSTALL_GLOBAL_DIR.get() { + if let Some(home_dir) = env_var::BUN_INSTALL_GLOBAL_DIR.get_not_empty() { return Dir::cwd() .make_open_path(home_dir, OpenDirOptions::default()) .map(|d| d.into_raw()) @@ -317,37 +355,16 @@ pub fn open_global_dir(explicit_global_dir: &[u8]) -> crate::Result .map_err(Into::into); } - if let Some(home_dir) = env_var::BUN_INSTALL.get() { - let mut buf = PathBuffer::uninit(); - let parts: [&[u8]; 2] = [b"install", b"global"]; - let path = join_abs_string_buf::(home_dir, &mut buf.0, &parts); - return Dir::cwd() - .make_open_path(path, OpenDirOptions::default()) - .map(|d| d.into_raw()) - .map_err(Into::into); - } - - if let Some(home_dir) = env_var::XDG_CACHE_HOME - .get() - .or_else(|| env_var::HOME.get()) - { - let mut buf = PathBuffer::uninit(); - let parts: [&[u8]; 3] = [b".bun", b"install", b"global"]; - let path = join_abs_string_buf::(home_dir, &mut buf.0, &parts); - return Dir::cwd() - .make_open_path(path, OpenDirOptions::default()) - .map(|d| d.into_raw()) - .map_err(Into::into); + match global_install_root() { + Some(root) => make_open_global_path(root, &[b"install", b"global"]), + None => Err(crate::Error::NoGlobalDirectoryFound), } - - Err(crate::Error::NoGlobalDirectoryFound) } pub(crate) fn open_global_bin_dir(opts_: Option<&Api::BunInstall>) -> crate::Result { - use bun_paths::{platform, resolve_path::join_abs_string_buf}; use bun_sys::{Dir, OpenDirOptions}; - if let Some(home_dir) = env_var::BUN_INSTALL_BIN.get() { + if let Some(home_dir) = env_var::BUN_INSTALL_BIN.get_not_empty() { return Dir::cwd() .make_open_path(home_dir, OpenDirOptions::default()) .map(|d| d.into_raw()) @@ -365,30 +382,10 @@ pub(crate) fn open_global_bin_dir(opts_: Option<&Api::BunInstall>) -> crate::Res } } - if let Some(home_dir) = env_var::BUN_INSTALL.get() { - let mut buf = PathBuffer::uninit(); - let parts: [&[u8]; 1] = [b"bin"]; - let path = join_abs_string_buf::(home_dir, &mut buf.0, &parts); - return Dir::cwd() - .make_open_path(path, OpenDirOptions::default()) - .map(|d| d.into_raw()) - .map_err(Into::into); - } - - if let Some(home_dir) = env_var::XDG_CACHE_HOME - .get() - .or_else(|| env_var::HOME.get()) - { - let mut buf = PathBuffer::uninit(); - let parts: [&[u8]; 2] = [b".bun", b"bin"]; - let path = join_abs_string_buf::(home_dir, &mut buf.0, &parts); - return Dir::cwd() - .make_open_path(path, OpenDirOptions::default()) - .map(|d| d.into_raw()) - .map_err(Into::into); + match global_install_root() { + Some(root) => make_open_global_path(root, &[b"bin"]), + None => Err(crate::Error::MissingGlobalBinDirectoryTrySettingBUNINSTALL), } - - Err(crate::Error::MissingGlobalBinDirectoryTrySettingBUNINSTALL) } // `BunInstall` owns `Box<[u8]>`; Options stores `&'static [u8]` diff --git a/src/install/repository.rs b/src/install/repository.rs index d48f93518218..5c637e3eaa53 100644 --- a/src/install/repository.rs +++ b/src/install/repository.rs @@ -118,14 +118,19 @@ impl SloppyGlobalGitConfig { } fn load_and_parse() -> SloppyGlobalGitConfig { - let Some(home_dir) = bun_core::env_var::HOME.get() else { + let Some(home_dir) = bun_core::env_var::HOME.get_not_empty() else { return SloppyGlobalGitConfig::default(); }; let mut config_file_path_buf = PathBuffer::uninit(); + let parts: [&[u8]; 2] = [home_dir, b".gitconfig"]; let config_file_path = bun_paths::resolve_path::join_abs_string_buf_z::< bun_paths::platform::Auto, - >(home_dir, &mut config_file_path_buf, &[b".gitconfig"]); + >( + bun_resolver::fs::FileSystem::get().top_level_dir(), + &mut config_file_path_buf, + &parts, + ); // MOVE_DOWN: `File::toSource` lives in `bun_logger` (T1→T2 cyclebreak). let Ok(source) = bun_ast::to_source( config_file_path, diff --git a/src/options_types/compile_target.rs b/src/options_types/compile_target.rs index 1875cab88df8..9fb6b6fd3d1f 100644 --- a/src/options_types/compile_target.rs +++ b/src/options_types/compile_target.rs @@ -185,7 +185,7 @@ impl CompileTarget { &self, buf: &'a mut PathBuffer, version_str: &'a ZStr, - _env: &mut bun_dotenv::Loader, + env: &bun_dotenv::Loader, needs_download: &mut bool, ) -> &'a ZStr { if self.is_default() { @@ -206,10 +206,11 @@ impl CompileTarget { return version_str; } - // T1 fallback ignores `_env` (full env-override chain lives in bun_install). - let cache_dir = bun_sys::fetch_cache_directory_path(); + let top_level_dir = path::fs::FileSystem::instance().top_level_dir(); + let cache_dir = + crate::install_cache_dir::fetch_cache_directory_path(top_level_dir, env, None); let dest = path::resolve_path::join_abs_string_buf_z::( - path::fs::FileSystem::instance().top_level_dir(), + top_level_dir, &mut buf[..], &[cache_dir.as_slice(), version_str.as_bytes()], ); diff --git a/src/options_types/install_cache_dir.rs b/src/options_types/install_cache_dir.rs new file mode 100644 index 000000000000..fb1dec0bfd43 --- /dev/null +++ b/src/options_types/install_cache_dir.rs @@ -0,0 +1,48 @@ +//! Location of the `bun install` cache. The package manager and +//! `bun build --compile` (which downloads the executables of other targets +//! into the cache) share this so that both honor the same settings. + +use bun_dotenv::Loader as DotEnvLoader; +use bun_paths::resolve_path::{join_abs_string, platform}; + +/// Resolves the cache directory. In order of precedence: `$BUN_INSTALL_CACHE_DIR`, +/// the configured `install.cache.dir` (`configured`), `$BUN_INSTALL/install/cache`, +/// `$XDG_CACHE_HOME/.bun/install/cache`, `$HOME/.bun/install/cache`, and +/// `node_modules/.bun-cache` when none of them is set. An empty value does not +/// select its candidate. +/// +/// Each candidate is joined onto `top_level_dir`, so a relative value names a +/// directory inside the project and an absolute one is used as is. +pub fn fetch_cache_directory_path( + top_level_dir: &[u8], + env: &DotEnvLoader, + configured: Option<&[u8]>, +) -> Vec { + let abs = |parts: &[&[u8]]| join_abs_string::(top_level_dir, parts).to_vec(); + + if let Some(dir) = not_empty(env.get(b"BUN_INSTALL_CACHE_DIR")) { + return abs(&[dir]); + } + + if let Some(dir) = not_empty(configured) { + return abs(&[dir]); + } + + if let Some(dir) = not_empty(env.get(b"BUN_INSTALL")) { + return abs(&[dir, b"install/", b"cache/"]); + } + + if let Some(dir) = bun_core::env_var::XDG_CACHE_HOME.get_not_empty() { + return abs(&[dir, b".bun/", b"install/", b"cache/"]); + } + + if let Some(dir) = bun_core::env_var::HOME.get_not_empty() { + return abs(&[dir, b".bun/", b"install/", b"cache/"]); + } + + abs(&[b"node_modules/.bun-cache"]) +} + +fn not_empty(value: Option<&[u8]>) -> Option<&[u8]> { + value.filter(|value| !value.is_empty()) +} diff --git a/src/options_types/lib.rs b/src/options_types/lib.rs index 41e3962eae45..c7b87dedb88a 100644 --- a/src/options_types/lib.rs +++ b/src/options_types/lib.rs @@ -8,6 +8,7 @@ pub mod compile_target; pub mod context; pub mod error; pub mod global_cache; +pub mod install_cache_dir; pub mod jsx; pub mod offline_mode; pub mod schema; diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index bf08481d40d4..9e362a537142 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -545,7 +545,7 @@ impl CronRegisterJob { } }; - let Some(home) = env_var::HOME.get() else { + let Some(home) = env_var::HOME.get_not_empty() else { self.set_err(format_args!("HOME environment variable not set")); return Err(()); }; @@ -1187,7 +1187,7 @@ impl CronJobBase for CronRemoveJob { impl CronRemoveJob { #[cfg(target_os = "macos")] fn unlink_plist(&self) { - let Some(home) = env_var::HOME.get() else { + let Some(home) = env_var::HOME.get_not_empty() else { self.set_err(format_args!("HOME not set")); return; }; diff --git a/src/runtime/cli/install_completions_command.rs b/src/runtime/cli/install_completions_command.rs index 09a5b4b08055..0011202fe710 100644 --- a/src/runtime/cli/install_completions_command.rs +++ b/src/runtime/cli/install_completions_command.rs @@ -54,7 +54,7 @@ impl InstallCompletionsCommand { } 'outer: { - if let Some(install_dir) = env_var::BUN_INSTALL.get() { + if let Some(install_dir) = env_var::BUN_INSTALL.get_not_empty() { let link_path = buf_print_z( &mut link_buf, format_args!("{}/bin/{}", bstr::BStr::new(install_dir), Self::BUNX_NAME), @@ -68,7 +68,7 @@ impl InstallCompletionsCommand { // if that fails, try $HOME/.bun/bin 'outer: { - if let Some(home_dir) = env_var::HOME.get() { + if let Some(home_dir) = env_var::HOME.get_not_empty() { let link_path = buf_print_z( &mut link_buf, format_args!("{}/.bun/bin/{}", bstr::BStr::new(home_dir), Self::BUNX_NAME), @@ -82,7 +82,7 @@ impl InstallCompletionsCommand { // if that fails, try $HOME/.local/bin 'outer: { - if let Some(home_dir) = env_var::HOME.get() { + if let Some(home_dir) = env_var::HOME.get_not_empty() { let link_path = buf_print_z( &mut link_buf, format_args!( diff --git a/src/runtime/cli/pm_diff_command.rs b/src/runtime/cli/pm_diff_command.rs index 3bc0de3d57f4..4e53f736a58c 100644 --- a/src/runtime/cli/pm_diff_command.rs +++ b/src/runtime/cli/pm_diff_command.rs @@ -89,9 +89,9 @@ pub(crate) fn exec( .iter() .map(|&arg| { use bun_paths::resolve_path::{join_abs_string, platform}; - match (arg.strip_prefix(b"~/"), bun_core::env_var::HOME.get()) { + match (arg.strip_prefix(b"~/"), bun_core::env_var::HOME.get_not_empty()) { (Some(rest), Some(home)) => { - join_abs_string::(home, &[rest]).to_vec() + join_abs_string::(original_cwd, &[home, rest]).to_vec() } _ if looks_like_path(arg) && !bun_paths::is_absolute(arg) => { join_abs_string::(original_cwd, &[arg]).to_vec() diff --git a/src/runtime/cli/upgrade_command.rs b/src/runtime/cli/upgrade_command.rs index fcd16bf1b0bf..5547b2a40649 100644 --- a/src/runtime/cli/upgrade_command.rs +++ b/src/runtime/cli/upgrade_command.rs @@ -912,7 +912,7 @@ impl UpgradeCommand { Some(p) => p, None => { let system_root = bun_core::env_var::SYSTEMROOT - .get() + .get_not_empty() .unwrap_or(b"C:\\Windows"); let hardcoded_system_powershell = bun_paths::join_abs_string_buf_z::( diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 1a1ec085173e..2be90ab0c04f 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -673,22 +673,25 @@ impl CompileC { .unwrap_or(b""), ]; + let top_level_dir = Fs::FileSystem::get().top_level_dir(); for sdkroot in dirs_to_try { if !sdkroot.is_empty() { + let include_parts: [&[u8]; 3] = [sdkroot, b"usr", b"include"]; let include_dir = path::resolve_path::join_abs_string_buf_z::< path::platform::Auto, >( - sdkroot, pathbuf.as_mut_slice(), &[b"usr", b"include"] + top_level_dir, pathbuf.as_mut_slice(), &include_parts ); if state.add_sys_include_path(include_dir).is_err() { global_this.throw(format_args!("TinyCC failed to add sysinclude path")); return Err(crate::Error::JSError); } + let lib_parts: [&[u8]; 3] = [sdkroot, b"usr", b"lib"]; let lib_dir = path::resolve_path::join_abs_string_buf_z::< path::platform::Auto, >( - sdkroot, pathbuf.as_mut_slice(), &[b"usr", b"lib"] + top_level_dir, pathbuf.as_mut_slice(), &lib_parts ); if state.add_library_path(lib_dir).is_err() { global_this.throw(format_args!("TinyCC failed to add library path")); diff --git a/src/runtime/webview/ChromeProcess.rs b/src/runtime/webview/ChromeProcess.rs index 29f0a8ef4ef9..a83833c60cdd 100644 --- a/src/runtime/webview/ChromeProcess.rs +++ b/src/runtime/webview/ChromeProcess.rs @@ -1056,9 +1056,15 @@ fn read_dev_tools_active_port(out_buf: &mut Vec) -> Option<()> { // names come from each browser's installer — hardcoded, not // discoverable. Edge uses the same CDP + file format as Chrome. #[cfg(windows)] - let root = getenv_z(zstr!("LOCALAPPDATA"))?; + let root: &[u8] = getenv_z(zstr!("LOCALAPPDATA"))?; #[cfg(not(windows))] - let root = getenv_z(zstr!("HOME"))?; + let root: &[u8] = getenv_z(zstr!("HOME"))?; + if root.is_empty() { + return None; + } + // A relative root is resolved against the working directory rather than + // being taken for a path under the filesystem root. + let top_level_dir = bun_paths::fs::FileSystem::instance().top_level_dir(); #[cfg(target_os = "macos")] let candidates: &[&[u8]] = &[ @@ -1098,8 +1104,12 @@ fn read_dev_tools_active_port(out_buf: &mut Vec) -> Option<()> { let mut path_buf = path_buffer_pool::get(); for rel in candidates { - let path = - resolve_path::join_abs_string_buf_z::(root, &mut path_buf[..], &[rel]); + let parts: [&[u8]; 2] = [root, rel]; + let path = resolve_path::join_abs_string_buf_z::( + top_level_dir, + &mut path_buf[..], + &parts, + ); let contents: Vec = match bun_sys::File::read_from(Fd::cwd(), path) { Err(_) => continue, // ENOENT or EACCES — try next Ok(c) => c, diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index f38a57bd3930..4fd07d4551a9 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -977,7 +977,8 @@ pub(crate) fn to_bytes( }; if Environment::IS_CANARY || Environment::IS_DEBUG { - if let Some(dump_code_dir) = bun_core::env_var::BUN_FEATURE_FLAG_DUMP_CODE.get() { + if let Some(dump_code_dir) = bun_core::env_var::BUN_FEATURE_FLAG_DUMP_CODE.get_not_empty() + { // `dest_path` keeps `..` for the embedded bunfs key below; neutralize // every `..` segment here so the on-disk dump can't escape // `dump_code_dir` (the join would otherwise normalize `..` above it). @@ -985,10 +986,11 @@ pub(crate) fn to_bytes( options::write_sanitized_parent_dirs(&mut dump_rel, dest_path) .expect("write to Vec"); let mut path_buf = bun_paths::path_buffer_pool::get(); + let parts: [&[u8]; 2] = [dump_code_dir, &dump_rel]; let dest_z = path::resolve_path::join_abs_string_buf_z::( - dump_code_dir, + bun_fs::FileSystem::instance().top_level_dir(), &mut path_buf[..], - &[&dump_rel], + &parts, ); // Scoped block to handle dump failures without skipping module emission diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 7f55a108d009..36d082d2a38b 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9206,22 +9206,6 @@ pub fn write_file_with_path_buffer( r.map(|_| buffer.len()) } -/// `bun.fetchCacheDirectoryPath` — resolve `$BUN_INSTALL_CACHE_DIR` / -/// `$XDG_CACHE_HOME/.bun/install/cache` / `$HOME/.bun/install/cache`. -/// full env-override chain lives in `bun_install`; this is the -/// fallback so the symbol resolves at T1. Returns an owned path (caller frees). -pub fn fetch_cache_directory_path() -> Vec { - if let Some(v) = bun_core::getenv_z(bun_core::zstr!("BUN_INSTALL_CACHE_DIR")) { - return v.to_vec(); - } - if let Some(home) = bun_core::getenv_z(bun_core::zstr!("HOME")) { - let mut p = home.to_vec(); - p.extend_from_slice(b"/.bun/install/cache"); - return p; - } - b".bun-cache".to_vec() -} - // ────────────────────────────────────────────────────────────────────────── // OUTPUT_SINK — bun_core's stderr vtable, installed by us at init (B-0 hook). // ────────────────────────────────────────────────────────────────────────── From 7fe377ac128fb9850e38613178b7a6e25217a30f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:13:07 +0000 Subject: [PATCH 04/10] Add tests for relative and empty directory variables --- test/bundler/bun-build-compile.test.ts | 120 ++++++++++++++++++++++++- test/cli/install/bun-pm-diff.test.ts | 22 +++++ test/cli/install/bun-pm.test.ts | 45 ++++++++++ test/cli/install/npmrc.test.ts | 41 +++++++++ test/js/bun/cron/cron.test.ts | 45 ++++++++++ 5 files changed, 272 insertions(+), 1 deletion(-) diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 25f16b630c86..0001429bbf27 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isArm64, isLinux, isMacOS, isMusl, isPosix, isWindows, tempDir } from "harness"; -import { chmodSync, closeSync, cpSync, existsSync, openSync, readSync } from "node:fs"; +import { chmodSync, closeSync, cpSync, existsSync, openSync, readdirSync, readSync } from "node:fs"; import { join } from "path"; describe("Bun.build compile", () => { @@ -766,4 +766,122 @@ describe("compiled binary in a deleted cwd", () => { ); }); +describe("compile target download cache", () => { + // Compiling for another platform downloads that platform's bun into the `bun install` + // cache. The location used to be derived from $HOME alone: $BUN_INSTALL was ignored and + // an empty $HOME put the cache at `/.bun/install/cache`. It now comes from the same + // lookup as `bun install` ($BUN_INSTALL_CACHE_DIR, $BUN_INSTALL, $XDG_CACHE_HOME, $HOME). + test("stores the downloaded executable under $BUN_INSTALL/install/cache", async () => { + const tarball = await new Bun.Archive({ "package/bin/bun": "not an executable\n" }, { compress: "gzip" }).bytes(); + const requests: string[] = []; + await using server = Bun.serve({ + port: 0, + fetch(req) { + requests.push(new URL(req.url).pathname); + return new Response(tarball); + }, + }); + + using dir = tempDir("build-compile-download-cache", { + "app.js": `console.log("hi");`, + }); + const cwd = String(dir); + const env: NodeJS.Dict = { + ...bunEnv, + HOME: join(cwd, "home"), + USERPROFILE: join(cwd, "home"), + BUN_INSTALL: join(cwd, "bun-install"), + BUN_COMPILE_TARGET_TARBALL_URL: `${server.url}bun.tgz`, + }; + delete env.BUN_INSTALL_CACHE_DIR; + delete env.XDG_CACHE_HOME; + + // Never the platform running the test, so that the download happens. + const target = isArm64 ? "bun-linux-x64" : "bun-linux-arm64"; + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", `--target=${target}`, "app.js", "--outfile", "app"], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + // The build then fails to turn the fake download into a program. Only where the + // download was stored matters here. + await proc.exited; + + expect(requests).toEqual(["/bun.tgz"]); + expect(readdirSync(join(cwd, "bun-install", "install", "cache"))).toEqual([expect.stringMatching(/^bun-linux-/)]); + expect(existsSync(join(cwd, "home"))).toBe(false); + }); +}); + +describe("embedded libraries with a relative temp directory", () => { + const cc = isLinux ? (Bun.which("cc") ?? Bun.which("gcc")) : null; + + // A compiled program extracts an embedded library into the temp directory through a + // directory handle, and hands dlopen() the temp directory joined with the file name. + // With `TMPDIR=rel-tmp` the file used to be written to `./rel-tmp/` while dlopen() was + // given `/el-tmp/` (the join took the first byte of the relative base for the + // root). The temp directory is resolved against the cwd once, so both agree. + test.skipIf(!isLinux || !cc)( + "a program extracts and loads an embedded library from a relative TMPDIR or BUN_TMPDIR", + async () => { + using dir = tempDir("build-compile-relative-tmpdir", { + "libhello.c": "int hello(void) { return 42; }\n", + "app.ts": ` + import { dlopen, FFIType } from "bun:ffi"; + import lib from "./libhello.so" with { type: "file" }; + try { + const { symbols } = dlopen(lib, { hello: { args: [], returns: FFIType.i32 } }); + console.log(symbols.hello()); + } catch (e) { + console.log(String(e)); + } + `, + "rel-tmp/.keep": "", + "rel-bun-tmp/.keep": "", + }); + const cwd = String(dir); + + { + await using proc = Bun.spawn({ cmd: [cc!, "-shared", "-fPIC", "-o", "libhello.so", "libhello.c"], cwd, env: bunEnv }); + expect(await proc.exited).toBe(0); + } + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", "--outfile", "app", "app.ts"], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + const extractedLibraries = (tmp: string) => readdirSync(join(cwd, tmp)).filter(name => name.endsWith(".so")); + + // BUN_TMPDIR takes precedence over TMPDIR inside bun, and CI sets both. + const withTmpdir: NodeJS.Dict = { ...bunEnv, TMPDIR: "rel-tmp" }; + delete withTmpdir.BUN_TMPDIR; + const withBunTmpdir: NodeJS.Dict = { ...bunEnv, BUN_TMPDIR: "rel-bun-tmp" }; + + for (const [env, tmp] of [ + [withTmpdir, "rel-tmp"], + [withBunTmpdir, "rel-bun-tmp"], + ] as const) { + await using proc = Bun.spawn({ cmd: [join(cwd, "app")], cwd, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), exitCode, extracted: extractedLibraries(tmp).length }).toEqual({ + stdout: "42", + exitCode: 0, + extracted: 1, + }); + } + }, + 60_000, + ); +}); + // file command test works well diff --git a/test/cli/install/bun-pm-diff.test.ts b/test/cli/install/bun-pm-diff.test.ts index 4a388e5eda8f..83c14e64c52c 100644 --- a/test/cli/install/bun-pm-diff.test.ts +++ b/test/cli/install/bun-pm-diff.test.ts @@ -947,6 +947,28 @@ describe.concurrent("bun pm diff (hostile and awkward inputs)", () => { expect(mixed.stdout.split("\n")[0]).toBe("./one.tar → diffme@2.0.0"); expect(mixed.exitCode).toBe(0); }); + + // `~/x` used to be joined with $HOME as the base of an absolute join, which turned a + // relative $HOME into a path under the root with its first byte missing (`/el-home/a`). + test("~/ with a relative $HOME resolves against the cwd", async () => { + using dir = tempDir("pm-diff-relative-home", { + "rel-home/a/package.json": JSON.stringify({ name: "diffme", version: "1.0.0" }), + "rel-home/b/package.json": JSON.stringify({ name: "diffme", version: "1.0.1" }), + }); + await using p = Bun.spawn({ + cmd: [bunExe(), "pm", "diff", "~/a", "~/b", "--name-only"], + cwd: String(dir), + env: { ...bunEnv, NO_COLOR: "1", HOME: "rel-home", USERPROFILE: "rel-home" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect({ stderr, exitCode, changed: stdout.split("\n").filter(l => /^[AMD] /.test(l)) }).toEqual({ + stderr: "", + exitCode: 0, + changed: ["M package.json"], + }); + }); }); // The pieces underneath the terminal view — name-free symbol matching, the alignment fallbacks, key→display map diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 790a7315b2e0..98b9fb6e9708 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1084,3 +1084,48 @@ test("bun pm cache rm does not create the directory named by a project-local .en expect(stderr).not.toContain("error"); expect(exitCode).toBe(0); }); + +// The global directory (`$BUN_INSTALL/install/global`, else `$XDG_CACHE_HOME/.bun/...`, +// else `$HOME/.bun/...`) used to be joined with the variable as the base of an +// absolute join. A relative value came out anchored at the filesystem root with its +// first byte missing (`rel-bun` -> `/el-bun/install/global`, `` -> `/nstall/global`). +// Relative values now resolve against the directory the command runs in, and the bin +// directory (opened after bun has changed into the global directory) comes from the +// same root. An empty value counts as unset. +for (const [title, envOverride, root] of [ + ["relative $BUN_INSTALL resolves against the cwd", { BUN_INSTALL: "rel-bun" }, ["rel-bun"]], + ["empty $BUN_INSTALL falls through to $HOME", { BUN_INSTALL: "" }, ["fake-home", ".bun"]], + ["relative $XDG_CACHE_HOME resolves against the cwd", { XDG_CACHE_HOME: "rel-xdg" }, ["rel-xdg", ".bun"]], + ["relative $HOME resolves against the cwd", { HOME: "rel-home", USERPROFILE: "rel-home" }, ["rel-home", ".bun"]], + ["unset $BUN_INSTALL falls through to $HOME", {}, ["fake-home", ".bun"]], +] as const) { + test(`bun pm bin -g: ${title}`, async () => { + // `bun pm bin -g` needs a package.json in the global directory it ends up in. + using dir = tempDir("pm-global-dir-env", { + [[...root, "install/global/package.json"].join("/")]: JSON.stringify({ name: "global", version: "1.0.0" }), + }); + const cwd = String(dir); + const binDir = join(cwd, ...root, "bin"); + + // bunEnv spreads process.env, where CI and developer machines set these. + const spawnEnv: NodeJS.Dict = { ...env, HOME: join(cwd, "fake-home"), USERPROFILE: join(cwd, "fake-home") }; + delete spawnEnv.BUN_INSTALL; + delete spawnEnv.BUN_INSTALL_GLOBAL_DIR; + delete spawnEnv.BUN_INSTALL_BIN; + delete spawnEnv.XDG_CACHE_HOME; + delete spawnEnv.XDG_CONFIG_HOME; + Object.assign(spawnEnv, envOverride); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "bin", "-g"], + cwd, + stdout: "pipe", + stderr: "pipe", + env: spawnEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: binDir, stderr: "", exitCode: 0 }); + expect(await exists(binDir)).toBeTrue(); + }); +} diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index ec2e6adfcaee..0900476bc556 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -222,6 +222,47 @@ registry = http://localhost:${registry.port}/ const result = await publishDryRun(String(dir), { XDG_CONFIG_HOME: "" }); expect(result).toEqual(usesRegistry(1)); }); + + // A relative directory variable used to be taken for a path under the filesystem + // root with its first byte dropped (`rel-xdg` -> `/el-xdg/.npmrc`). It resolves + // against the directory the command runs in, like every other relative path. + it.concurrent("resolves a relative $XDG_CONFIG_HOME against the cwd", async () => { + using dir = tempDir("npmrc-xdg-relative", { + ...pkg, + "home/.npmrc": npmrc(1), + "pkg/rel-xdg/.npmrc": npmrc(2), + }); + const result = await publishDryRun(String(dir), { XDG_CONFIG_HOME: "rel-xdg" }); + expect(result).toEqual(usesRegistry(2)); + }); + + it.concurrent("resolves a relative $HOME against the cwd", async () => { + using dir = tempDir("npmrc-home-relative", { ...pkg, "pkg/rel-home/.npmrc": npmrc(2) }); + const result = await publishDryRun(String(dir), { HOME: "rel-home", USERPROFILE: "rel-home" }); + expect(result).toEqual(usesRegistry(2)); + }); + + // The user-level bunfig.toml is looked up in the same directories. Its registry + // takes precedence over the one in $HOME/.npmrc, which shows that it was read. + it.concurrent("reads .bunfig.toml from a relative $XDG_CONFIG_HOME", async () => { + using dir = tempDir("bunfig-xdg-relative", { + ...pkg, + "home/.npmrc": npmrc(1), + "pkg/rel-xdg/.bunfig.toml": `[install]\nregistry = { url = "http://localhost:2/", token = "token" }\n`, + }); + const result = await publishDryRun(String(dir), { XDG_CONFIG_HOME: "rel-xdg" }); + expect(result).toEqual(usesRegistry(2)); + }); + + it.concurrent("reads .bunfig.toml from a relative $HOME", async () => { + using dir = tempDir("bunfig-home-relative", { + ...pkg, + "pkg/rel-home/.npmrc": npmrc(1), + "pkg/rel-home/.bunfig.toml": `[install]\nregistry = { url = "http://localhost:2/", token = "token" }\n`, + }); + const result = await publishDryRun(String(dir), { HOME: "rel-home", USERPROFILE: "rel-home" }); + expect(result).toEqual(usesRegistry(2)); + }); }); it("package config overrides home config", async () => { diff --git a/test/js/bun/cron/cron.test.ts b/test/js/bun/cron/cron.test.ts index 4bca6d3f9bd4..dad655d5fb66 100644 --- a/test/js/bun/cron/cron.test.ts +++ b/test/js/bun/cron/cron.test.ts @@ -701,6 +701,51 @@ describe.skipIf(!hasCrontab)("cron removal (Linux)", () => { }); }); +// Uses a fake `crontab` found through PATH, so it needs no cron daemon and +// leaves the real crontab alone. +describe.skipIf(!isLinux)("crontab temp file (Linux)", () => { + // The new crontab is written to a temp file in $TMPDIR and `crontab ` is run. + // With a relative TMPDIR the file name used to be built as `//` (`cron-tmp` -> `/ron-tmp/...`), so registration failed with "Failed + // to create temp file". A relative temp directory now resolves against the cwd. + test("writes the crontab to a relative TMPDIR resolved against the cwd", async () => { + using dir = tempDir("bun-cron-relative-tmpdir", { + "job.ts": `export default { scheduled() {} };`, + "register.ts": ` + console.log(await Bun.cron("./job.ts", "0 3 * * *", "relative-tmpdir").then(() => "registered", e => "failed: " + e.message)); + `, + "cron-tmp/.keep": "", + "fake-bin/.keep": "", + }); + const cwd = String(dir); + const record = `${cwd}/crontab-argument.txt`; + writeFileSync(`${cwd}/fake-bin/crontab`, `#!/bin/sh\n[ "$1" = -l ] && exit 0\nprintf '%s' "$1" > '${record}'\n`, { + mode: 0o755, + }); + + const env: NodeJS.Dict = { + ...bunEnv, + PATH: `${cwd}/fake-bin:${bunEnv.PATH}`, + TMPDIR: "cron-tmp", + }; + delete env.TMP; + delete env.TEMP; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "register.ts"], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "registered", exitCode: 0 }); + expect(stderr).toBe(""); + expect(readFileSync(record, "utf8")).toStartWith(`${cwd}/cron-tmp/`); + }); +}); + // ========================================================================== // Registration & Removal (Windows — schtasks) // ========================================================================== From efc2ef5397ebf06170a01a121b81ca96d9192bf0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:30:55 +0000 Subject: [PATCH 05/10] paths: size spill and checked joins for a base resolved against the working directory --- src/paths/resolve_path.rs | 42 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 2a672644810d..63529dac69dd 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1392,7 +1392,7 @@ pub fn join_abs_string_spill<'a, P: PlatformT>( parts: &[&[u8]], ) -> &'a [u8] { debug_assert!(!matches!(P::P, Platform::Nt)); - let needed = join_abs_needed(cwd.len(), parts); + let needed = join_abs_result_capacity::

(cwd, parts); if needed <= PARSER_JOIN_INPUT_BUFFER_LEN { return join_abs_string::

(cwd, parts); } @@ -1634,6 +1634,20 @@ fn join_abs_needed(cwd_len: usize, parts: &[&[u8]]) -> usize { parts.iter().map(|p| p.len() + 1).sum::() + cwd_len + 2 } +/// Output capacity that holds the result of joining `parts` onto `cwd`. A base +/// that is not absolute is resolved against the working directory +/// (`join_onto_working_dir`), so the result may also hold that directory, which +/// fits in a `PathBuffer`. +#[inline] +fn join_abs_result_capacity(cwd: &[u8], parts: &[&[u8]]) -> usize { + let needed = join_abs_needed(cwd.len(), parts); + if P::P.is_absolute(cwd) { + needed + } else { + needed + MAX_PATH_BYTES + } +} + /// Scratch buffer for `_join_abs_string_buf`'s unnormalized concatenation. /// Draws from the /// thread-local `path_buffer_pool` for the common case and only heap-allocates @@ -1685,7 +1699,7 @@ pub fn join_abs_string_buf_checked<'a, P: PlatformT>( debug_assert!(!matches!(P::P, Platform::Nt)); // Fast path: size check only — don't allocate a JoinScratch here since the // inner join_abs_string_buf already has its own (avoids doubling stack usage). - let total = join_abs_needed(cwd.len(), parts); + let total = join_abs_result_capacity::

(cwd, parts); if total < buf.len() { return Some(join_abs_string_buf::

(cwd, buf, parts)); } @@ -2772,6 +2786,30 @@ mod tests { ); } + #[test] + fn join_abs_checked_and_spill_account_for_the_working_dir_prefix() { + record_working_dir(); + + // `rel` plus the part fits the 64-byte buffer, `/work/rel/` plus the part does not. + let part = [b'a'; 56]; + let mut buf = [0u8; 64]; + assert_eq!( + join_abs_string_buf_checked::(b"rel", &mut buf, &[&part]), + None + ); + + // The part alone fits the thread-local buffer, the prefixed result does not. + let part = vec![b'a'; PARSER_JOIN_INPUT_BUFFER_LEN - 4]; + let mut expected = b"/work/".to_vec(); + expected.extend_from_slice(&part); + let mut spill = Vec::new(); + assert_eq!( + join_abs_string_spill::(b"", &mut spill, &[&part]), + &expected[..] + ); + assert!(!spill.is_empty()); + } + #[test] fn join_abs_windows_resolves_a_relative_or_empty_base_against_the_working_dir() { record_working_dir(); From 7a00fd9f558001f805c7e746946726a5dbc8a6cd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:46:26 +0000 Subject: [PATCH 06/10] [autofix.ci] apply automated fixes --- src/install/PackageManager.rs | 5 ++--- src/install/PackageManager/PackageManagerOptions.rs | 9 +++++++-- src/install/repository.rs | 13 ++++++------- src/runtime/cli/pm_diff_command.rs | 5 ++++- src/standalone_graph/StandaloneModuleGraph.rs | 3 ++- test/bundler/bun-build-compile.test.ts | 6 +++++- 6 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 836e7fcb831d..1e7ac6844c23 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1935,9 +1935,8 @@ pub fn init( let mut global_len: usize = 0; if let Some(xdg_dir) = bun_core::env_var::XDG_CONFIG_HOME.get_not_empty() { let parts: [&[u8]; 2] = [xdg_dir, b".npmrc"]; - let p = resolve_path::join_abs_string_buf_z::( - started_in, &mut buf, &parts, - ); + let p = + resolve_path::join_abs_string_buf_z::(started_in, &mut buf, &parts); if bun_sys::exists_z(p) { global_len = p.len(); } diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index f74cc813dc14..358a6c338c22 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -314,13 +314,18 @@ fn global_install_root() -> Option<&'static [u8]> { let mut cwd_buf = bun_paths::path_buffer_pool::get(); let cwd = resolve_path::working_dir(&mut cwd_buf); if let Some(dir) = env_var::BUN_INSTALL.get_not_empty() { - return Some(Box::from(resolve_path::join_abs_string::(cwd, &[dir]))); + return Some(Box::from(resolve_path::join_abs_string::( + cwd, + &[dir], + ))); } let home_dir = env_var::XDG_CACHE_HOME .get_not_empty() .or_else(|| env_var::HOME.get_not_empty())?; let parts: [&[u8]; 2] = [home_dir, b".bun"]; - Some(Box::from(resolve_path::join_abs_string::(cwd, &parts))) + Some(Box::from(resolve_path::join_abs_string::( + cwd, &parts, + ))) }) .as_deref() } diff --git a/src/install/repository.rs b/src/install/repository.rs index 5c637e3eaa53..1550f7e0fbb6 100644 --- a/src/install/repository.rs +++ b/src/install/repository.rs @@ -124,13 +124,12 @@ impl SloppyGlobalGitConfig { let mut config_file_path_buf = PathBuffer::uninit(); let parts: [&[u8]; 2] = [home_dir, b".gitconfig"]; - let config_file_path = bun_paths::resolve_path::join_abs_string_buf_z::< - bun_paths::platform::Auto, - >( - bun_resolver::fs::FileSystem::get().top_level_dir(), - &mut config_file_path_buf, - &parts, - ); + let config_file_path = + bun_paths::resolve_path::join_abs_string_buf_z::( + bun_resolver::fs::FileSystem::get().top_level_dir(), + &mut config_file_path_buf, + &parts, + ); // MOVE_DOWN: `File::toSource` lives in `bun_logger` (T1→T2 cyclebreak). let Ok(source) = bun_ast::to_source( config_file_path, diff --git a/src/runtime/cli/pm_diff_command.rs b/src/runtime/cli/pm_diff_command.rs index 4e53f736a58c..91e9a36e4562 100644 --- a/src/runtime/cli/pm_diff_command.rs +++ b/src/runtime/cli/pm_diff_command.rs @@ -89,7 +89,10 @@ pub(crate) fn exec( .iter() .map(|&arg| { use bun_paths::resolve_path::{join_abs_string, platform}; - match (arg.strip_prefix(b"~/"), bun_core::env_var::HOME.get_not_empty()) { + match ( + arg.strip_prefix(b"~/"), + bun_core::env_var::HOME.get_not_empty(), + ) { (Some(rest), Some(home)) => { join_abs_string::(original_cwd, &[home, rest]).to_vec() } diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 4fd07d4551a9..8ffc3ecf93df 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -977,7 +977,8 @@ pub(crate) fn to_bytes( }; if Environment::IS_CANARY || Environment::IS_DEBUG { - if let Some(dump_code_dir) = bun_core::env_var::BUN_FEATURE_FLAG_DUMP_CODE.get_not_empty() + if let Some(dump_code_dir) = + bun_core::env_var::BUN_FEATURE_FLAG_DUMP_CODE.get_not_empty() { // `dest_path` keeps `..` for the embedded bunfs key below; neutralize // every `..` segment here so the on-disk dump can't escape diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 0001429bbf27..19a1e770d763 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -844,7 +844,11 @@ describe("embedded libraries with a relative temp directory", () => { const cwd = String(dir); { - await using proc = Bun.spawn({ cmd: [cc!, "-shared", "-fPIC", "-o", "libhello.so", "libhello.c"], cwd, env: bunEnv }); + await using proc = Bun.spawn({ + cmd: [cc!, "-shared", "-fPIC", "-o", "libhello.so", "libhello.c"], + cwd, + env: bunEnv, + }); expect(await proc.exited).toBe(0); } { From 15865a1e82ef2f384f5a4cf1ec86e7f7ee5eaaf2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:05:52 +0000 Subject: [PATCH 07/10] Shorten comments and widen the global directory test matrix --- src/bunfig/arguments.rs | 6 ++-- src/install/PackageManager.rs | 4 +-- .../PackageManager/PackageManagerOptions.rs | 9 +---- src/options_types/install_cache_dir.rs | 13 ++----- src/paths/resolve_path.rs | 35 ++++--------------- src/resolver/lib.rs | 9 ++--- src/runtime/webview/ChromeProcess.rs | 2 -- test/bundler/bun-build-compile.test.ts | 5 +-- test/cli/install/bun-pm.test.ts | 6 ++++ test/js/bun/cron/cron.test.ts | 1 + 10 files changed, 25 insertions(+), 65 deletions(-) diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index 518d63c89dd4..b0c79ccb5418 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -17,8 +17,7 @@ use crate::bunfig::Bunfig; // ─── bunfig loading ────────────────────────────────────────────────────────── -/// `$XDG_CONFIG_HOME/.bunfig.toml`, else `$HOME/.bunfig.toml`. An empty -/// variable is ignored. A relative one is resolved against `cwd`. +/// `$XDG_CONFIG_HOME/.bunfig.toml`, else `$HOME/.bunfig.toml`; a relative directory is resolved against `cwd`. fn get_home_config_path<'b>(cwd: &[u8], buf: &'b mut PathBuffer) -> Option<&'b ZStr> { let config_dir = env_var::XDG_CONFIG_HOME .get_not_empty() @@ -28,8 +27,7 @@ fn get_home_config_path<'b>(cwd: &[u8], buf: &'b mut PathBuffer) -> Option<&'b Z Some(ZStr::from_buf(&buf[..], len)) } -/// `Arguments::parse` records the cwd (after `--cwd`) before any config is -/// loaded. Callers that do not go through it get the live cwd. +/// Recorded by `Arguments::parse` (after `--cwd`); callers that skip it get the live cwd. fn absolute_working_dir(ctx: &mut ContextData) -> Option<&[u8]> { if ctx.args.absolute_working_dir.is_none() { let mut buf = PathBuffer::uninit(); diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 1e7ac6844c23..3b29a91e4ac2 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1921,9 +1921,7 @@ pub fn init( let npmrc_local = ZBox::from_bytes(b".npmrc"); let mut buf = PathBuffer::uninit(); - // A relative `$XDG_CONFIG_HOME` or `$HOME` is resolved against the - // directory the command was started in. The process has already - // changed into the workspace root (or, for `-g`, the global directory). + // The process has since changed into the workspace root or, for `-g`, the global directory. let started_in: &[u8] = ctx .args .absolute_working_dir diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 358a6c338c22..73850a913f89 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -298,14 +298,7 @@ pub use crate::config_version::ConfigVersion; pub use bun_install_types::DependencyGroup; pub use bun_install_types::NodeLinker::NodeLinker; -/// The directory that holds the global `install/global` and `bin` directories: -/// `$BUN_INSTALL`, else `$XDG_CACHE_HOME/.bun`, else `$HOME/.bun`. An empty -/// variable is ignored. -/// -/// A relative value is resolved against the working directory once. The global -/// directory is opened before a global install changes into it and the global -/// bin directory after, so resolving at each use would put them in different -/// places. +/// Resolved once: `-g` changes into the global directory between opening it and opening the bin directory. fn global_install_root() -> Option<&'static [u8]> { static ROOT: bun_core::Once>> = bun_core::Once::new(); ROOT.get_or_init(|| { diff --git a/src/options_types/install_cache_dir.rs b/src/options_types/install_cache_dir.rs index fb1dec0bfd43..627b6f8954c5 100644 --- a/src/options_types/install_cache_dir.rs +++ b/src/options_types/install_cache_dir.rs @@ -1,18 +1,9 @@ -//! Location of the `bun install` cache. The package manager and -//! `bun build --compile` (which downloads the executables of other targets -//! into the cache) share this so that both honor the same settings. +//! The `bun install` cache directory, shared with `bun build --compile`, which downloads other targets into it. use bun_dotenv::Loader as DotEnvLoader; use bun_paths::resolve_path::{join_abs_string, platform}; -/// Resolves the cache directory. In order of precedence: `$BUN_INSTALL_CACHE_DIR`, -/// the configured `install.cache.dir` (`configured`), `$BUN_INSTALL/install/cache`, -/// `$XDG_CACHE_HOME/.bun/install/cache`, `$HOME/.bun/install/cache`, and -/// `node_modules/.bun-cache` when none of them is set. An empty value does not -/// select its candidate. -/// -/// Each candidate is joined onto `top_level_dir`, so a relative value names a -/// directory inside the project and an absolute one is used as is. +/// `configured` is the bunfig `install.cache.dir`. Every candidate is joined onto `top_level_dir`; an empty one is skipped. pub fn fetch_cache_directory_path( top_level_dir: &[u8], env: &DotEnvLoader, diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 63529dac69dd..01baed27d46c 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1634,10 +1634,7 @@ fn join_abs_needed(cwd_len: usize, parts: &[&[u8]]) -> usize { parts.iter().map(|p| p.len() + 1).sum::() + cwd_len + 2 } -/// Output capacity that holds the result of joining `parts` onto `cwd`. A base -/// that is not absolute is resolved against the working directory -/// (`join_onto_working_dir`), so the result may also hold that directory, which -/// fits in a `PathBuffer`. +/// `join_abs_needed`, plus the working directory (at most a `PathBuffer`) that a non-absolute base gets prepended. #[inline] fn join_abs_result_capacity(cwd: &[u8], parts: &[&[u8]]) -> usize { let needed = join_abs_needed(cwd.len(), parts); @@ -1727,12 +1724,7 @@ pub fn join_abs_string_buf_z<'a, P: PlatformT>( unsafe { ZStr::from_raw(r.as_ptr(), r.len()) } } -/// Directory that a non-absolute `join_abs*` base resolves against: the -/// top-level directory once one has been recorded, otherwise the live working -/// directory. Returns an empty slice when neither is available (the join then -/// anchors the path at the root). Callers that store an environment-supplied -/// directory for later joins resolve it against this once, so that every -/// consumer names the same directory even if the process changes directory. +/// What a non-absolute `join_abs*` base resolves against: the recorded top-level directory, else the cwd, else empty. pub fn working_dir(buf: &mut PathBuffer) -> &[u8] { let top_level_dir = bun_core::top_level_dir(); if crate::is_absolute(top_level_dir) { @@ -1744,10 +1736,7 @@ pub fn working_dir(buf: &mut PathBuffer) -> &[u8] { } } -/// Joins `parts`, none of which is absolute, onto the working directory. This -/// is the fallback for a join whose base is not absolute, such as an empty or -/// relative `$HOME` or `$TMPDIR`: `("rel", ["x"])` resolves to `/rel/x`. -/// `parts` must not be empty, so the result always lands in `buf`. +/// Fallback for a base that is not absolute. `parts` must not be empty, so the result always lands in `buf`. #[cold] #[inline(never)] fn join_onto_working_dir<'a, const IS_SENTINEL: bool, P: PlatformT>( @@ -1757,8 +1746,7 @@ fn join_onto_working_dir<'a, const IS_SENTINEL: bool, P: PlatformT>( debug_assert!(!parts.is_empty()); let mut cwd_buf = crate::path_buffer_pool::get(); let cwd = working_dir(&mut cwd_buf); - // `/` is absolute under every platform's rule, so the nested join cannot - // end up back here. + // `/` is absolute under every platform's rule, so the nested join cannot come back here. let cwd: &[u8] = if P::P.is_absolute(cwd) { cwd } else { b"/" }; let len = _join_abs_string_buf::(cwd, &mut *buf, parts).len(); &buf[..len] @@ -1766,9 +1754,6 @@ fn join_onto_working_dir<'a, const IS_SENTINEL: bool, P: PlatformT>( // We always return `&[u8]`; when `IS_SENTINEL` a NUL is written // at `result.len()` and callers (e.g. `join_abs_string_buf_z`) re-wrap as `ZStr`. -// -// `_cwd` should be absolute. When it is not and no part is absolute either, -// the result is resolved against the working directory (`join_onto_working_dir`). fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( _cwd: &'a [u8], buf: &'a mut [u8], @@ -1857,10 +1842,7 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( } let Some(i) = P::P.leading_separator_index::(&temp_buf[0..out]) else { - // Nothing anchors the path: `cwd` is empty or relative and no part is - // absolute. Inventing a root here would take the first byte of the - // path for the separator (`("rel", ["x"])` became `/el/x`), so resolve - // the relative path against the working directory instead. + // `cwd` is empty or relative and no part is absolute; inventing a `/` here ate the first byte (`rel/x` -> `/el/x`). let relative: &[u8] = &temp_buf[0..out]; return join_onto_working_dir::(buf, &[relative]); }; @@ -1899,9 +1881,7 @@ fn join_abs_string_buf_windows<'a, const IS_SENTINEL: bool>( parts: &[&[u8]], ) -> &'a [u8] { if !crate::is_absolute_windows(cwd) { - // Same fallback as the POSIX arm: an empty or relative `cwd` is the - // first segment of a path resolved against the working directory. An - // absolute part still wins, as the nested join sees it too. + // Same fallback as the POSIX arm; an absolute part still wins inside the nested join. let mut all_parts: Vec<&[u8]> = Vec::with_capacity(parts.len() + 1); all_parts.push(cwd); all_parts.extend_from_slice(parts); @@ -2701,8 +2681,7 @@ mod tests { assert_eq!(out, b"/work/sub"); } - /// `/` is absolute under the POSIX rule and the Windows rule alike, so one - /// recorded working directory serves the tests of both arms. + /// `/work` is absolute under the POSIX rule and the Windows rule alike, so it serves the tests of both arms. fn record_working_dir() { bun_core::set_top_level_dir(b"/work"); } diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index aee46f461b7c..60e1ea1e7cec 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -1703,11 +1703,7 @@ pub mod fs { } } - /// A directory taken from the environment can be relative - /// (`TMPDIR=reltmp`). The directory handle is opened from this string - /// relative to the cwd, while the paths handed out for the files inside - /// it are joined onto this string, so it is resolved once here and both - /// name the same directory. + /// `TMPDIR=reltmp` is resolved once, so the handle opened from it and the paths joined onto it name the same directory. fn absolute_temp_dir(dir: Cow<'static, [u8]>) -> Cow<'static, [u8]> { if bun_paths::is_absolute(&dir) { return dir; @@ -1727,8 +1723,7 @@ pub mod fs { ONCE.get_or_init(Self::platform_temp_dir_compute) } - /// Non-empty `BUN_TMPDIR`, falling back to `platform_temp_dir`. Computed - /// once per process. Always absolute. + /// Non-empty `BUN_TMPDIR`, falling back to `platform_temp_dir`; computed once per process, always absolute. pub fn tmpdir_path() -> &'static [u8] { static ONCE: bun_core::Once> = bun_core::Once::new(); ONCE.get_or_init(|| match bun_core::env_var::BUN_TMPDIR.get_not_empty() { diff --git a/src/runtime/webview/ChromeProcess.rs b/src/runtime/webview/ChromeProcess.rs index a83833c60cdd..fe517bd57e98 100644 --- a/src/runtime/webview/ChromeProcess.rs +++ b/src/runtime/webview/ChromeProcess.rs @@ -1062,8 +1062,6 @@ fn read_dev_tools_active_port(out_buf: &mut Vec) -> Option<()> { if root.is_empty() { return None; } - // A relative root is resolved against the working directory rather than - // being taken for a path under the filesystem root. let top_level_dir = bun_paths::fs::FileSystem::instance().top_level_dir(); #[cfg(target_os = "macos")] diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 19a1e770d763..9048432f0b28 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -876,9 +876,10 @@ describe("embedded libraries with a relative temp directory", () => { [withBunTmpdir, "rel-bun-tmp"], ] as const) { await using proc = Bun.spawn({ cmd: [join(cwd, "app")], cwd, env, stdout: "pipe", stderr: "pipe" }); - const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), exitCode, extracted: extractedLibraries(tmp).length }).toEqual({ + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode, extracted: extractedLibraries(tmp).length }).toEqual({ stdout: "42", + stderr: expect.not.stringContaining("error"), exitCode: 0, extracted: 1, }); diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 98b9fb6e9708..98e95c858a86 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1095,7 +1095,13 @@ test("bun pm cache rm does not create the directory named by a project-local .en for (const [title, envOverride, root] of [ ["relative $BUN_INSTALL resolves against the cwd", { BUN_INSTALL: "rel-bun" }, ["rel-bun"]], ["empty $BUN_INSTALL falls through to $HOME", { BUN_INSTALL: "" }, ["fake-home", ".bun"]], + [ + "empty $BUN_INSTALL falls through to a relative $XDG_CACHE_HOME", + { BUN_INSTALL: "", XDG_CACHE_HOME: "rel-xdg" }, + ["rel-xdg", ".bun"], + ], ["relative $XDG_CACHE_HOME resolves against the cwd", { XDG_CACHE_HOME: "rel-xdg" }, ["rel-xdg", ".bun"]], + ["empty $XDG_CACHE_HOME falls through to $HOME", { XDG_CACHE_HOME: "" }, ["fake-home", ".bun"]], ["relative $HOME resolves against the cwd", { HOME: "rel-home", USERPROFILE: "rel-home" }, ["rel-home", ".bun"]], ["unset $BUN_INSTALL falls through to $HOME", {}, ["fake-home", ".bun"]], ] as const) { diff --git a/test/js/bun/cron/cron.test.ts b/test/js/bun/cron/cron.test.ts index dad655d5fb66..e19530b840da 100644 --- a/test/js/bun/cron/cron.test.ts +++ b/test/js/bun/cron/cron.test.ts @@ -728,6 +728,7 @@ describe.skipIf(!isLinux)("crontab temp file (Linux)", () => { PATH: `${cwd}/fake-bin:${bunEnv.PATH}`, TMPDIR: "cron-tmp", }; + delete env.BUN_TMPDIR; delete env.TMP; delete env.TEMP; From 75d620eef8b23f907ec9c702319111d999ed260e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:28:48 +0000 Subject: [PATCH 08/10] paths: build the unanchored fallback in place instead of re-entering the join A part that is_absolute accepts but leading_separator_index does not anchor (c:/x or :://x under Loose) was promoted to the base on every re-entry, so join_abs_string::(dir, [":://filesystem"]) recursed until the process died. The unanchored base is now a segment under the base it was given or under the working directory, with no recursion. --- src/paths/resolve_path.rs | 106 ++++++++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 34 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 01baed27d46c..4320b69fa0f4 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1634,11 +1634,11 @@ fn join_abs_needed(cwd_len: usize, parts: &[&[u8]]) -> usize { parts.iter().map(|p| p.len() + 1).sum::() + cwd_len + 2 } -/// `join_abs_needed`, plus the working directory (at most a `PathBuffer`) that a non-absolute base gets prepended. +/// `join_abs_needed`, plus the working directory (at most a `PathBuffer`) that an unanchored base gets prepended. #[inline] fn join_abs_result_capacity(cwd: &[u8], parts: &[&[u8]]) -> usize { let needed = join_abs_needed(cwd.len(), parts); - if P::P.is_absolute(cwd) { + if P::P.is_absolute(cwd) && P::P.leading_separator_index::(cwd).is_some() { needed } else { needed + MAX_PATH_BYTES @@ -1736,22 +1736,6 @@ pub fn working_dir(buf: &mut PathBuffer) -> &[u8] { } } -/// Fallback for a base that is not absolute. `parts` must not be empty, so the result always lands in `buf`. -#[cold] -#[inline(never)] -fn join_onto_working_dir<'a, const IS_SENTINEL: bool, P: PlatformT>( - buf: &'a mut [u8], - parts: &[&[u8]], -) -> &'a [u8] { - debug_assert!(!parts.is_empty()); - let mut cwd_buf = crate::path_buffer_pool::get(); - let cwd = working_dir(&mut cwd_buf); - // `/` is absolute under every platform's rule, so the nested join cannot come back here. - let cwd: &[u8] = if P::P.is_absolute(cwd) { cwd } else { b"/" }; - let len = _join_abs_string_buf::(cwd, &mut *buf, parts).len(); - &buf[..len] -} - // We always return `&[u8]`; when `IS_SENTINEL` a NUL is written // at `result.len()` and callers (e.g. `join_abs_string_buf_z`) re-wrap as `ZStr`. fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( @@ -1796,11 +1780,14 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( return &buf[0..1]; } - let mut cwd = if cfg!(windows) && _cwd.len() >= 3 && _cwd[1] == b':' { + let base: &[u8] = if cfg!(windows) && _cwd.len() >= 3 && _cwd[1] == b':' { &_cwd[2..] } else { _cwd }; + let mut cwd = base; + // `is_absolute` accepts what `leading_separator_index` may not anchor (`c:/x` under `Loose`), so a promoted part can still be unanchored below. + let mut promoted = false; { let mut part_i: u16 = 0; @@ -1809,6 +1796,7 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( while part_i < part_len { if P::P.is_absolute(parts[part_i as usize]) { cwd = parts[part_i as usize]; + promoted = true; parts = &parts[part_i as usize + 1..]; part_len = parts.len() as u16; @@ -1819,19 +1807,40 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( } } - let mut scratch = JoinScratch::init(cwd.len(), parts); + // An unanchored `cwd` (empty, relative) becomes the first segment under an anchored head instead of having a `/` invented over its first byte. + let mut working_dir_buf: Option = None; + let (head, i, segment): (&[u8], usize, &[u8]) = match P::P.leading_separator_index::(cwd) { + Some(i) => (cwd, i, b""), + None => { + let mut anchor = None; + if promoted { + anchor = P::P.leading_separator_index::(base).map(|i| (base, i)); + } + let (anchor, i) = match anchor { + Some(anchor) => anchor, + None => { + let dir = working_dir(working_dir_buf.insert(crate::path_buffer_pool::get())); + match P::P.leading_separator_index::(dir) { + Some(i) => (dir, i), + None => (b"/" as &[u8], 0), + } + } + }; + (anchor, i, cwd) + } + }; + + let mut scratch = JoinScratch::init(head.len() + segment.len() + 1, parts); let temp_buf = scratch.buf(); - temp_buf[..cwd.len()].copy_from_slice(cwd); - let mut out: usize = cwd.len(); + temp_buf[..head.len()].copy_from_slice(head); + let mut out: usize = head.len(); - for &_part in parts { - if _part.is_empty() { + for part in core::iter::once(segment).chain(parts.iter().copied()) { + if part.is_empty() { continue; } - let part = _part; - if out > 0 && temp_buf[out - 1] != P::P.separator() { temp_buf[out] = P::P.separator(); out += 1; @@ -1841,12 +1850,6 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( out += part.len(); } - let Some(i) = P::P.leading_separator_index::(&temp_buf[0..out]) else { - // `cwd` is empty or relative and no part is absolute; inventing a `/` here ate the first byte (`rel/x` -> `/el/x`). - let relative: &[u8] = &temp_buf[0..out]; - return join_onto_working_dir::(buf, &[relative]); - }; - // reshaped for borrowck — stash leading separator into a local // [u8; 8] (max len: NT prefix `\\?\` = 4) so we don't hold a borrow into // temp_buf across the normalize call below. @@ -1881,11 +1884,19 @@ fn join_abs_string_buf_windows<'a, const IS_SENTINEL: bool>( parts: &[&[u8]], ) -> &'a [u8] { if !crate::is_absolute_windows(cwd) { - // Same fallback as the POSIX arm; an absolute part still wins inside the nested join. + // `cwd` becomes the first part under the working directory; that base passes the check above, so this recurses once. + let mut working_dir_buf = crate::path_buffer_pool::get(); + let dir = working_dir(&mut working_dir_buf); + let dir: &[u8] = if crate::is_absolute_windows(dir) { + dir + } else { + b"\\" + }; let mut all_parts: Vec<&[u8]> = Vec::with_capacity(parts.len() + 1); all_parts.push(cwd); all_parts.extend_from_slice(parts); - return join_onto_working_dir::(buf, &all_parts); + let len = join_abs_string_buf_windows::(dir, &mut *buf, &all_parts).len(); + return &buf[..len]; } if parts.is_empty() { @@ -2738,6 +2749,33 @@ mod tests { ); } + #[test] + fn join_abs_loose_keeps_a_part_that_is_absolute_but_not_anchored_under_its_base() { + record_working_dir(); + // `Loose` calls `c:/cache` absolute, but only an upper-case drive anchors a path. + assert_eq!( + join_abs_string::(b"/project", &[b"c:/cache", b"pkg"]), + b"/project/c:/cache/pkg" + ); + assert_eq!( + join_abs_string::(b"rel", &[b"c:/cache"]), + b"/work/c:/cache" + ); + assert_eq!( + join_abs_string_z::(b"/project", &[b"1:\\x"]).as_bytes(), + b"/project/1:/x" + ); + // `import(":://filesystem")` reaches the resolver's joins with this part. + assert_eq!( + join_abs_string::(b"/project", &[b":://filesystem"]), + b"/project/::/filesystem" + ); + assert_eq!( + join_abs_string::(b"/project", &[b"C:/cache"]), + b"C:/cache" + ); + } + #[test] fn join_abs_z_with_a_relative_base_is_nul_terminated_in_the_buffer() { record_working_dir(); From 1793a073196836671d29626d46ebb5d0a6ec8917 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:39:01 +0000 Subject: [PATCH 09/10] install: resolve the global root before an explicit global directory short-circuits Otherwise the first use is open_global_bin_dir, after -g has changed into the global directory, and a relative HOME resolves against that directory. --- src/install/PackageManager/PackageManagerOptions.rs | 5 ++++- test/bundler/bun-build-compile.test.ts | 7 ++++--- test/cli/install/bun-pm.test.ts | 9 ++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 73850a913f89..c9b5bd6ab4fa 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -339,6 +339,9 @@ fn make_open_global_path(root: &[u8], parts: &[&[u8]]) -> crate::Result crate::Result { use bun_sys::{Dir, OpenDirOptions}; + // Resolved here even when an explicit directory wins below: `open_global_bin_dir` runs after the `-g` chdir. + let root = global_install_root(); + if let Some(home_dir) = env_var::BUN_INSTALL_GLOBAL_DIR.get_not_empty() { return Dir::cwd() .make_open_path(home_dir, OpenDirOptions::default()) @@ -353,7 +356,7 @@ pub fn open_global_dir(explicit_global_dir: &[u8]) -> crate::Result .map_err(Into::into); } - match global_install_root() { + match root { Some(root) => make_open_global_path(root, &[b"install", b"global"]), None => Err(crate::Error::NoGlobalDirectoryFound), } diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 9048432f0b28..d40fce43e11e 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -805,10 +805,11 @@ describe("compile target download cache", () => { stdout: "pipe", stderr: "pipe", }); - // The build then fails to turn the fake download into a program. Only where the - // download was stored matters here. - await proc.exited; + const [, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The build fails later, when the fake download is turned into a program. The + // download itself has to succeed and land in the cache. + expect(stderr).not.toMatch(/download|corrupted/i); expect(requests).toEqual(["/bun.tgz"]); expect(readdirSync(join(cwd, "bun-install", "install", "cache"))).toEqual([expect.stringMatching(/^bun-linux-/)]); expect(existsSync(join(cwd, "home"))).toBe(false); diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 98e95c858a86..817b92d7cf35 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1103,12 +1103,19 @@ for (const [title, envOverride, root] of [ ["relative $XDG_CACHE_HOME resolves against the cwd", { XDG_CACHE_HOME: "rel-xdg" }, ["rel-xdg", ".bun"]], ["empty $XDG_CACHE_HOME falls through to $HOME", { XDG_CACHE_HOME: "" }, ["fake-home", ".bun"]], ["relative $HOME resolves against the cwd", { HOME: "rel-home", USERPROFILE: "rel-home" }, ["rel-home", ".bun"]], + [ + "relative $HOME still resolves against the cwd when $BUN_INSTALL_GLOBAL_DIR picks the global directory", + { BUN_INSTALL_GLOBAL_DIR: "explicit-global", HOME: "rel-home", USERPROFILE: "rel-home" }, + ["rel-home", ".bun"], + ], ["unset $BUN_INSTALL falls through to $HOME", {}, ["fake-home", ".bun"]], ] as const) { test(`bun pm bin -g: ${title}`, async () => { // `bun pm bin -g` needs a package.json in the global directory it ends up in. + const globalDir = + (envOverride as Record).BUN_INSTALL_GLOBAL_DIR ?? [...root, "install/global"].join("/"); using dir = tempDir("pm-global-dir-env", { - [[...root, "install/global/package.json"].join("/")]: JSON.stringify({ name: "global", version: "1.0.0" }), + [`${globalDir}/package.json`]: JSON.stringify({ name: "global", version: "1.0.0" }), }); const cwd = String(dir); const binDir = join(cwd, ...root, "bin"); From c9421d25fbb483bcca1d9e27c7f0f4c36577b1f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:53:10 +0000 Subject: [PATCH 10/10] paths: keep the empty-parts fast path ahead of the Windows fallback, as on POSIX --- src/paths/resolve_path.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 4320b69fa0f4..651c6bd6df00 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1883,6 +1883,13 @@ fn join_abs_string_buf_windows<'a, const IS_SENTINEL: bool>( buf: &'a mut [u8], parts: &[&[u8]], ) -> &'a [u8] { + if parts.is_empty() { + if IS_SENTINEL { + unreachable!(); + } + return cwd; + } + if !crate::is_absolute_windows(cwd) { // `cwd` becomes the first part under the working directory; that base passes the check above, so this recurses once. let mut working_dir_buf = crate::path_buffer_pool::get(); @@ -1899,13 +1906,6 @@ fn join_abs_string_buf_windows<'a, const IS_SENTINEL: bool>( return &buf[..len]; } - if parts.is_empty() { - if IS_SENTINEL { - unreachable!(); - } - return cwd; - } - // path.resolve is a bit different on Windows, as there are multiple possible filesystem roots. // When you resolve(`C:\hello`, `C:world`), the second arg is a drive letter relative path, so // the result of such is `C:\hello\world`, but if you used D:world, you would switch roots and @@ -2852,6 +2852,13 @@ mod tests { ); } + #[test] + fn join_abs_with_no_parts_returns_the_base_unchanged_on_both_arms() { + record_working_dir(); + assert_eq!(join_abs_string::(b"rel", &[]), b"rel"); + assert_eq!(join_abs_string::(b"rel", &[]), b"rel"); + } + #[test] fn join_abs_with_an_absolute_base_does_not_consult_the_working_dir() { record_working_dir();