From f72ea8706f2623f008630439201d4cf0d2c8e570 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:14:03 +0000 Subject: [PATCH 1/8] install: skip non-absolute $BUN_INSTALL when locating global dirs open_global_dir and open_global_bin_dir passed the raw value of $BUN_INSTALL / $XDG_CACHE_HOME / $HOME as the cwd argument to join_abs_string_buf. That function requires an absolute base: on Windows it asserts (Sentry BUN-2V31, ~100 events in _joinAbsStringBufWindows via PackageManager.init), and on POSIX a relative base yields a rooted path with the first byte dropped (BUN_INSTALL='' created /nstall/global). These env vars are user input and can be empty or relative (e.g. BUN_INSTALL=~/.bun copied to a Windows shell where ~ is not expanded). Filter to absolute values so the lookup falls through to the next candidate instead of crashing or opening a bogus path. Resolving against the process cwd was considered but rejected because cwd changes between open_global_dir and open_global_bin_dir. Apply the same filter at the two other sites that hand these env vars to join_abs_string_buf as a base (.npmrc and .gitconfig lookup). --- src/install/PackageManager.rs | 12 ++++- .../PackageManager/PackageManagerOptions.rs | 30 ++++++++---- src/install/repository.rs | 5 +- test/cli/install/bun-pm.test.ts | 49 ++++++++++++++++++- 4 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index ed5bf8a3dd28..677f574209dd 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1927,8 +1927,13 @@ pub fn init( // npm reads `$HOME/.npmrc` and ignores XDG_CONFIG_HOME; keep // `$XDG_CONFIG_HOME/.npmrc` only when that file actually exists. + // Non-absolute values are skipped: `join_abs_string_buf_z` requires + // an absolute base (asserts on Windows, mangles on POSIX). let mut global_len: usize = 0; - if let Some(xdg_dir) = bun_core::env_var::XDG_CONFIG_HOME.get_not_empty() { + if let Some(xdg_dir) = bun_core::env_var::XDG_CONFIG_HOME + .get_not_empty() + .filter(|p| bun_paths::is_absolute(p)) + { let p = resolve_path::join_abs_string_buf_z::(xdg_dir, &mut buf, &parts); if bun_sys::exists_z(p) { @@ -1936,7 +1941,10 @@ pub fn init( } } if global_len == 0 { - if let Some(home_dir) = bun_core::env_var::HOME.get_not_empty() { + if let Some(home_dir) = bun_core::env_var::HOME + .get_not_empty() + .filter(|p| bun_paths::is_absolute(p)) + { global_len = resolve_path::join_abs_string_buf_z::( home_dir, &mut buf, &parts, ) diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 10504ff5a1cd..8fb490b4a0a7 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -298,12 +298,24 @@ pub use crate::config_version::ConfigVersion; pub use bun_install_types::DependencyGroup; pub use bun_install_types::NodeLinker::NodeLinker; +// `join_abs_string_buf` below requires an absolute base: on Windows it +// asserts, and on POSIX a relative base yields a rooted path with the first +// byte dropped. These env vars are user input and can be empty or relative +// (e.g. `BUN_INSTALL=~/.bun` copied to a Windows shell where `~` is not +// expanded), so skip values that are not absolute and fall through to the +// next candidate. Resolving against the process cwd is not an option because +// cwd changes between `open_global_dir` and `open_global_bin_dir`. +#[inline] +fn get_abs(v: Option<&'static [u8]>) -> Option<&'static [u8]> { + v.filter(|p| bun_paths::is_absolute(p)) +} + // 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,7 +329,7 @@ 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() { + if let Some(home_dir) = get_abs(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); @@ -327,9 +339,8 @@ pub fn open_global_dir(explicit_global_dir: &[u8]) -> crate::Result .map_err(Into::into); } - if let Some(home_dir) = env_var::XDG_CACHE_HOME - .get() - .or_else(|| env_var::HOME.get()) + if let Some(home_dir) = + get_abs(env_var::XDG_CACHE_HOME.get()).or_else(|| get_abs(env_var::HOME.get())) { let mut buf = PathBuffer::uninit(); let parts: [&[u8]; 3] = [b".bun", b"install", b"global"]; @@ -347,7 +358,7 @@ pub(crate) fn open_global_bin_dir(opts_: Option<&Api::BunInstall>) -> crate::Res 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,7 +376,7 @@ pub(crate) fn open_global_bin_dir(opts_: Option<&Api::BunInstall>) -> crate::Res } } - if let Some(home_dir) = env_var::BUN_INSTALL.get() { + if let Some(home_dir) = get_abs(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); @@ -375,9 +386,8 @@ pub(crate) fn open_global_bin_dir(opts_: Option<&Api::BunInstall>) -> crate::Res .map_err(Into::into); } - if let Some(home_dir) = env_var::XDG_CACHE_HOME - .get() - .or_else(|| env_var::HOME.get()) + if let Some(home_dir) = + get_abs(env_var::XDG_CACHE_HOME.get()).or_else(|| get_abs(env_var::HOME.get())) { let mut buf = PathBuffer::uninit(); let parts: [&[u8]; 2] = [b".bun", b"bin"]; diff --git a/src/install/repository.rs b/src/install/repository.rs index d48f93518218..bf57e967bc8f 100644 --- a/src/install/repository.rs +++ b/src/install/repository.rs @@ -118,7 +118,10 @@ 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() + .filter(|p| Path::is_absolute(p)) + else { return SloppyGlobalGitConfig::default(); }; diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index d0c54586c249..a1a60dd7d455 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -2,7 +2,7 @@ import { spawn } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, expect, it, test } from "bun:test"; import { exists, mkdir, writeFile } from "fs/promises"; import { bunEnv, bunExe, bunEnv as env, readdirSorted, tempDir, tmpdirSync } from "harness"; -import { cpSync } from "node:fs"; +import { cpSync, realpathSync } from "node:fs"; import { join } from "path"; import { dummyAfterAll, @@ -936,3 +936,50 @@ 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); }); + +// https://bun-p9.sentry.io/issues/7403306202/ +// Windows panicked in _joinAbsStringBufWindows when $BUN_INSTALL was not an +// absolute path; POSIX silently opened a mangled path rooted at "/". Now +// empty and relative values are skipped so the next candidate ($HOME) wins. +for (const [title, bunInstallValue, base] of [ + ["empty $BUN_INSTALL falls through to $HOME", "", ["fake-home", ".bun"]], + ["relative $BUN_INSTALL falls through to $HOME", "relative-dir", ["fake-home", ".bun"]], + ["absolute $BUN_INSTALL", null, ["abs-bun"]], +] as const) { + test(`global dir: ${title}`, async () => { + using dir = tempDir("pm-global-dir-env", { + "package.json": JSON.stringify({ name: "pm-global-dir-env", version: "1.0.0" }), + }); + const cwd = String(dir); + const globalDir = join(cwd, ...base, "install", "global"); + const binDir = join(cwd, ...base, "bin"); + await mkdir(globalDir, { recursive: true }); + await writeFile(join(globalDir, "package.json"), JSON.stringify({ name: "global", version: "1.0.0" })); + + const spawnEnv: NodeJS.Dict = { + ...env, + BUN_INSTALL: bunInstallValue ?? join(cwd, "abs-bun"), + HOME: join(cwd, "fake-home"), + USERPROFILE: join(cwd, "fake-home"), + }; + delete spawnEnv.BUN_INSTALL_GLOBAL_DIR; + delete spawnEnv.BUN_INSTALL_BIN; + delete spawnEnv.XDG_CACHE_HOME; + + 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({ stderr, exitCode }).toEqual({ + stderr: expect.not.stringContaining("error:"), + exitCode: 0, + }); + expect(await exists(binDir)).toBeTrue(); + expect(realpathSync(stdout.trim())).toBe(realpathSync(binDir)); + }); +} From 1cc2362d872d5e71444e01440f15d636a9f564e0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:12:16 +0000 Subject: [PATCH 2/8] bunfig: filter non-absolute XDG_CONFIG_HOME/HOME in get_home_config_path Same pattern as the other join_abs_string_buf call sites in this PR: a relative or empty env var value passed as the base to join_abs_string_buf_z asserts on Windows and yields a mangled path on POSIX. Filter per-candidate so a bad XDG_CONFIG_HOME falls through to HOME instead of returning a bogus path or skipping the global .bunfig.toml entirely. --- src/bunfig/arguments.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index d0a388d3ef7c..a44e9879021d 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -20,13 +20,16 @@ use crate::bunfig::Bunfig; 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() { + if let Some(data_dir) = env_var::XDG_CONFIG_HOME + .get() + .filter(|p| bun_paths::is_absolute(p)) + { return Some(resolve_path::join_abs_string_buf_z::( data_dir, &mut **buf, &paths, )); } - if let Some(home_dir) = env_var::HOME.get() { + if let Some(home_dir) = env_var::HOME.get().filter(|p| bun_paths::is_absolute(p)) { return Some(resolve_path::join_abs_string_buf_z::( home_dir, &mut **buf, &paths, )); From 87438cd431a5aefb58f127bc4fb530a1c205bc8d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:39:01 +0000 Subject: [PATCH 3/8] test: clear XDG_CONFIG_HOME in global dir tests A host XDG_CONFIG_HOME pointing at a .bunfig.toml with [install].globalDir or globalBinDir would override the env-var chain the test exercises. Clear it alongside the other overrides so the test is hermetic. --- test/cli/install/bun-pm.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index a1a60dd7d455..5f89a1c35933 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -965,6 +965,7 @@ for (const [title, bunInstallValue, base] of [ delete spawnEnv.BUN_INSTALL_GLOBAL_DIR; delete spawnEnv.BUN_INSTALL_BIN; delete spawnEnv.XDG_CACHE_HOME; + delete spawnEnv.XDG_CONFIG_HOME; await using proc = Bun.spawn({ cmd: [bunExe(), "pm", "bin", "-g"], From f2276ef82cd1fcd90ccb281b864f6b06e1ebe6af Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:09:36 +0000 Subject: [PATCH 4/8] ci: retrigger From cf3c087c88705a42adeeeb1e60f90f4af76069e6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:30:07 +0000 Subject: [PATCH 5/8] Shorten comments on the absolute-base filters --- src/install/PackageManager.rs | 3 +-- src/install/PackageManager/PackageManagerOptions.rs | 11 ++++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 677f574209dd..45052225b083 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1927,8 +1927,7 @@ pub fn init( // npm reads `$HOME/.npmrc` and ignores XDG_CONFIG_HOME; keep // `$XDG_CONFIG_HOME/.npmrc` only when that file actually exists. - // Non-absolute values are skipped: `join_abs_string_buf_z` requires - // an absolute base (asserts on Windows, mangles on POSIX). + // Non-absolute values are skipped (join requires an absolute base). let mut global_len: usize = 0; if let Some(xdg_dir) = bun_core::env_var::XDG_CONFIG_HOME .get_not_empty() diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 8fb490b4a0a7..c8db0e32f1b3 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -298,13 +298,10 @@ pub use crate::config_version::ConfigVersion; pub use bun_install_types::DependencyGroup; pub use bun_install_types::NodeLinker::NodeLinker; -// `join_abs_string_buf` below requires an absolute base: on Windows it -// asserts, and on POSIX a relative base yields a rooted path with the first -// byte dropped. These env vars are user input and can be empty or relative -// (e.g. `BUN_INSTALL=~/.bun` copied to a Windows shell where `~` is not -// expanded), so skip values that are not absolute and fall through to the -// next candidate. Resolving against the process cwd is not an option because -// cwd changes between `open_global_dir` and `open_global_bin_dir`. +// `join_abs_string_buf` requires an absolute base (asserts on Windows, +// mangles on POSIX). A non-absolute env var falls through to the next +// candidate; cwd is not a valid resolve base here because it changes +// between `open_global_dir` and `open_global_bin_dir`. #[inline] fn get_abs(v: Option<&'static [u8]>) -> Option<&'static [u8]> { v.filter(|p| bun_paths::is_absolute(p)) From 3d5237e3cc2b1e8ec6ba7f4ddaa582230867bb95 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:32:09 +0000 Subject: [PATCH 6/8] Trim the get_abs comment --- src/install/PackageManager/PackageManagerOptions.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index c8db0e32f1b3..309e391cba0a 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -298,10 +298,8 @@ pub use crate::config_version::ConfigVersion; pub use bun_install_types::DependencyGroup; pub use bun_install_types::NodeLinker::NodeLinker; -// `join_abs_string_buf` requires an absolute base (asserts on Windows, -// mangles on POSIX). A non-absolute env var falls through to the next -// candidate; cwd is not a valid resolve base here because it changes -// between `open_global_dir` and `open_global_bin_dir`. +// `join_abs_string_buf` asserts an absolute base. A non-absolute env var +// falls through to the next candidate. #[inline] fn get_abs(v: Option<&'static [u8]>) -> Option<&'static [u8]> { v.filter(|p| bun_paths::is_absolute(p)) From 33c6ecf673fbaeb261b9b407364ee7660492fdb2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:41:27 +0000 Subject: [PATCH 7/8] test: assert stderr is empty in global dir tests --- test/cli/install/bun-pm.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 5f89a1c35933..6a26d41a987b 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -976,10 +976,7 @@ for (const [title, bunInstallValue, base] of [ }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stderr, exitCode }).toEqual({ - stderr: expect.not.stringContaining("error:"), - exitCode: 0, - }); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); expect(await exists(binDir)).toBeTrue(); expect(realpathSync(stdout.trim())).toBe(realpathSync(binDir)); }); From cb7368a54884ca6944a5b41e4e1da3dcf8316a19 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:48:59 +0000 Subject: [PATCH 8/8] pm diff: skip non-absolute HOME in tilde expansion Same class as the other sites: a non-absolute $HOME passed as the base to join_abs_string asserts on Windows and mangles on POSIX. Filtered out, a ~/-prefixed argument falls through to the looks_like_path arm and resolves against original_cwd. Also trim two comments flagged by comment-cop. --- src/install/PackageManager.rs | 1 - src/install/PackageManager/PackageManagerOptions.rs | 3 +-- src/runtime/cli/pm_diff_command.rs | 7 ++++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 45052225b083..5161b5df236a 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1927,7 +1927,6 @@ pub fn init( // npm reads `$HOME/.npmrc` and ignores XDG_CONFIG_HOME; keep // `$XDG_CONFIG_HOME/.npmrc` only when that file actually exists. - // Non-absolute values are skipped (join requires an absolute base). let mut global_len: usize = 0; if let Some(xdg_dir) = bun_core::env_var::XDG_CONFIG_HOME .get_not_empty() diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 309e391cba0a..9e332fe53540 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -298,8 +298,7 @@ pub use crate::config_version::ConfigVersion; pub use bun_install_types::DependencyGroup; pub use bun_install_types::NodeLinker::NodeLinker; -// `join_abs_string_buf` asserts an absolute base. A non-absolute env var -// falls through to the next candidate. +// `join_abs_string_buf` asserts an absolute base; skip non-absolute env values (BUN-2V31). #[inline] fn get_abs(v: Option<&'static [u8]>) -> Option<&'static [u8]> { v.filter(|p| bun_paths::is_absolute(p)) diff --git a/src/runtime/cli/pm_diff_command.rs b/src/runtime/cli/pm_diff_command.rs index 3bc0de3d57f4..1aa2c89c8d4a 100644 --- a/src/runtime/cli/pm_diff_command.rs +++ b/src/runtime/cli/pm_diff_command.rs @@ -89,7 +89,12 @@ 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() + .filter(|p| bun_paths::is_absolute(p)), + ) { (Some(rest), Some(home)) => { join_abs_string::(home, &[rest]).to_vec() }