From 019ead725bd1fa3d5e84b18b9b380c35c2f9e76b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 23:02:05 +0000 Subject: [PATCH 01/11] bunx: store the package cache under the per-user bun cache directory On POSIX, the bunx install cache now lives at /.bunx-/@ under the same install cache directory bun install uses (BUN_INSTALL_CACHE_DIR / BUN_INSTALL / XDG_CACHE_HOME / HOME), with the .bunx- subtree created with mode 0700. When no install cache directory can be resolved, bunx keeps the previous $TMPDIR/bunx--@ layout. Windows behavior is unchanged. After creating or opening the cache directory, bunx re-checks that it is a directory owned by the current user and not group/other writable before using it. --- src/runtime/cli/bunx_command.rs | 81 +++++++++++++++++++++++++--- test/cli/install/bunx.test.ts | 94 +++++++++++++++++++++++++++++---- 2 files changed, 157 insertions(+), 18 deletions(-) diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 41aeb5854d19..07019ca15ffe 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -872,7 +872,14 @@ impl BunxCommand { }; // PORT NOTE: `defer ctx.allocator.free(PATH_FOR_BIN_DIRS)` — Vec drops automatically. - // The bunx cache path is at the following location + // The bunx cache path is at the following location on POSIX, keyed per + // user under the same install cache directory `bun install` uses + // (BUN_INSTALL_CACHE_DIR / BUN_INSTALL / XDG_CACHE_HOME / HOME): + // + // /.bunx-//node_modules/.bin/ + // + // On Windows, and on POSIX when no install cache directory can be + // resolved, the cache stays in the temp directory: // // /bunx--/node_modules/.bin/ // @@ -893,6 +900,48 @@ impl BunxCommand { #[cfg(windows)] let uid = bun_sys::windows::user_unique_id(); + #[cfg(unix)] + let user_install_cache_root: Option> = { + let cache_dir = + bun_install::package_manager::fetch_cache_directory_path(env_loader, None); + if cache_dir.is_node_modules { + None + } else { + let mut root = cache_dir.path; + while root.last() == Some(&(bun_paths::SEP as u8)) { + root.pop(); + } + Some(root) + } + }; + #[cfg(not(unix))] + let user_install_cache_root: Option> = None; + + let bunx_cache_dir_buf: Vec = { + let mut v = Vec::new(); + match &user_install_cache_root { + Some(install_cache_root) => write!( + &mut v, + "{cache}{sep}.bunx-{uid}{sep}{pkg}", + cache = BStr::new(install_cache_root), + sep = bun_paths::SEP as char, + uid = uid, + pkg = BStr::new(&package_fmt), + ), + None => write!( + &mut v, + "{tmp}{sep}bunx-{uid}-{pkg}", + tmp = BStr::new(temp_dir), + sep = bun_paths::SEP as char, + uid = uid, + pkg = BStr::new(&package_fmt), + ), + } + .map_err(|_| bun_core::err!("OutOfMemory"))?; + v + }; + let bunx_cache_dir: &[u8] = &bunx_cache_dir_buf; + // PORT NOTE: Zig used `switch (PATH.len > 0) { inline else => |path_is_nonzero| ... }` // to monomorphize the format string. Collapsed to a runtime branch. // PERF(port): was comptime bool dispatch — profile if it shows up on a hot path. @@ -901,11 +950,9 @@ impl BunxCommand { let path_is_nonzero = !path.is_empty(); write!( &mut v, - "{tmp}{sep}bunx-{uid}-{pkg}{sep}node_modules{sep}.bin", - tmp = BStr::new(temp_dir), + "{cache}{sep}node_modules{sep}.bin", + cache = BStr::new(bunx_cache_dir), sep = bun_paths::SEP as char, - uid = uid, - pkg = BStr::new(&package_fmt), ) .map_err(|_| bun_core::err!("OutOfMemory"))?; if path_is_nonzero { @@ -918,9 +965,6 @@ impl BunxCommand { env_loader.map.put(b"PATH", &path)?; // SAFETY: `Transpiler::init` always sets `fs` to the process singleton. let fs = unsafe { &mut *this_transpiler.fs }; - let uid_digits = bun_core::fmt::digit_count(uid); - let bunx_cache_dir: &[u8] = - &path[0..temp_dir.len() + b"/bunx--".len() + package_fmt.len() + uid_digits]; bun_output::scoped_log!(bunx, "bunx_cache_dir: {}", BStr::new(bunx_cache_dir)); @@ -1253,8 +1297,29 @@ impl BunxCommand { Global::exit(1); } + #[cfg(unix)] + if let Some(install_cache_root) = &user_install_cache_root { + Fd::cwd().make_path(install_cache_root)?; + bun_sys::mkdir_recursive_at_mode(Fd::cwd(), bunx_cache_dir, 0o700)?; + } let bunx_install_dir = Fd::cwd().make_open_path(bunx_cache_dir)?; + { + let mut cache_root_buf = PathBuffer::uninit(); + cache_root_buf[..bunx_cache_dir.len()].copy_from_slice(bunx_cache_dir); + cache_root_buf[bunx_cache_dir.len()] = 0; + if !Self::is_trusted_cache_root( + ZStr::from_buf(&cache_root_buf[..], bunx_cache_dir.len()), + uid, + ) { + Output::err_generic( + "refusing to use bunx cache directory {} because it is not a directory owned by the current user. Remove it and try again.", + format_args!("{}", BStr::new(bunx_cache_dir)), + ); + Global::exit(1); + } + } + 'create_package_json: { // create package.json, but only if it doesn't exist let package_json = match bun_sys::File::create( diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index cb99066c38c7..9fabb8c802c7 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -2,7 +2,7 @@ import { spawn } from "bun"; import { afterAll, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test"; import { mkdir, rm, writeFile } from "fs/promises"; import { bunEnv, bunExe, isWindows, readdirSorted, tmpdirSync } from "harness"; -import { chmodSync, copyFileSync, readdirSync, symlinkSync } from "node:fs"; +import { chmodSync, copyFileSync, readdirSync, statSync, symlinkSync } from "node:fs"; import { tmpdir } from "os"; import { delimiter, join, resolve } from "path"; import { dummyAfterAll, dummyBeforeAll, dummyBeforeEach, dummyRegistry, getPort, setHandler } from "./dummy.registry"; @@ -1232,20 +1232,19 @@ it.skipIf(!isWindows)("should not crash on corrupted .bunx file with missing quo expect(stderr).not.toContain("reached unreachable code"); }); -// The bunx cache root lives at a predictable path inside the shared temp dir -// ($TMPDIR/bunx--@). bunx must refuse to reuse a -// pre-existing cache root that is not a private directory owned by the -// current user, because the owner of that directory can replace any of the -// cached package's module files after install. The check happens before any -// network or filesystem access inside the cache, so this test is fully -// offline. The check is Unix-only (no uid/world-writable-tmp model on -// Windows). +// The bunx cache root lives at a predictable path inside the per-user bun +// install cache (/.bunx-/@). bunx must +// refuse to reuse a pre-existing cache root that is not a private directory +// owned by the current user, because the owner of that directory can replace +// any of the cached package's module files after install. The check happens +// before any network or filesystem access inside the cache, so this test is +// fully offline. The check is Unix-only (no uid model on Windows). it.concurrent.skipIf(isWindows)( "refuses to reuse a bunx cache directory that other local users can modify", async () => { const { x_dir, env } = setup(); const pkg = "bunx-cache-root-fixture"; - const cacheRoot = join(env.TMPDIR, `bunx-${process.getuid!()}-${pkg}@latest`); + const cacheRoot = join(env.BUN_INSTALL_CACHE_DIR, `.bunx-${process.getuid!()}`, `${pkg}@latest`); const run = () => { const subprocess = spawn({ @@ -1300,3 +1299,78 @@ it.concurrent.skipIf(isWindows)( } }, ); + +// On POSIX the bunx package cache is stored under the per-user bun install +// cache directory (BUN_INSTALL_CACHE_DIR here), inside a 0700 `.bunx-` +// directory, instead of the shared temp dir. +it.concurrent.skipIf(isWindows)("stores the package cache under the per-user bun install cache directory", async () => { + const { x_dir, env } = setup(); + const uid = process.getuid!(); + + const run = async (args: string[]) => { + const subprocess = spawn({ + cmd: [bunExe(), "x", ...args], + cwd: x_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + return Promise.all([subprocess.stderr.text(), subprocess.stdout.text(), subprocess.exited] as const); + }; + + { + const [err, out, exitCode] = await run(["uglify-js@3.14.1", "-v"]); + expect(err).not.toContain("error:"); + expect(out.split(/\r?\n/)).toEqual(["uglify-js 3.14.1", ""]); + expect(exitCode).toBe(0); + } + + const userCacheRoot = join(env.BUN_INSTALL_CACHE_DIR, `.bunx-${uid}`); + const rootStat = statSync(userCacheRoot); + expect(rootStat.isDirectory()).toBe(true); + expect(rootStat.mode & 0o777).toBe(0o700); + expect(statSync(join(userCacheRoot, "uglify-js@3.14.1", "node_modules", ".bin")).isDirectory()).toBe(true); + expect(readdirSync(env.TMPDIR).filter(entry => entry.startsWith("bunx-"))).toEqual([]); + + // The cache at the new location is reused across runs. + { + const [err, out, exitCode] = await run(["--no-install", "uglify-js@3.14.1", "-v"]); + expect(err).not.toContain("error:"); + expect(out.split(/\r?\n/)).toEqual(["uglify-js 3.14.1", ""]); + expect(exitCode).toBe(0); + } +}); + +// When no install cache directory can be resolved (no BUN_INSTALL_CACHE_DIR, +// BUN_INSTALL, XDG_CACHE_HOME, or HOME), the cache falls back to the temp +// directory, keyed by uid and package as before. +it.concurrent.skipIf(isWindows)( + "falls back to the temp directory when no install cache directory can be resolved", + async () => { + const { x_dir, env } = setup(); + const uid = process.getuid!(); + delete env.BUN_INSTALL_CACHE_DIR; + delete env.BUN_INSTALL; + delete env.XDG_CACHE_HOME; + delete env.HOME; + + const subprocess = spawn({ + cmd: [bunExe(), "x", "uglify-js@3.14.1", "-v"], + cwd: x_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [err, out, exitCode] = await Promise.all([ + subprocess.stderr.text(), + subprocess.stdout.text(), + subprocess.exited, + ]); + expect(err).not.toContain("error:"); + expect(out.split(/\r?\n/)).toEqual(["uglify-js 3.14.1", ""]); + expect(exitCode).toBe(0); + expect(statSync(join(env.TMPDIR, `bunx-${uid}-uglify-js@3.14.1`, "node_modules", ".bin")).isDirectory()).toBe(true); + }, +); From eef8afb544210e450ed493f93bcfb1fa5873798d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 23:55:45 +0000 Subject: [PATCH 02/11] pm cache rm: count bunx packages stored under the install cache --- src/runtime/cli/package_manager_command.rs | 45 ++++++++++++++++------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 1ad195c405dd..f688c6bc0bb2 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -376,12 +376,37 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; if pm.options.positionals.len() > 1 && strings::eql_comptime(pm.options.positionals[1], b"rm") { + #[cfg(unix)] + // SAFETY: getuid(2) is always-successful with no preconditions. + let uid = unsafe { libc::getuid() }; + #[cfg(not(unix))] + let uid = bun_sys::windows::user_unique_id(); + let mut had_err = false; let mut env_map = bun_dotenv::Map::init(); let mut process_env = bun_dotenv::Loader::init(&mut env_map); process_env.load_process()?; let cache_dir = fetch_cache_directory_path(&mut process_env, None); + + let mut deleted: usize = 0; + let mut bunx_root: Option> = None; + if !cache_dir.is_node_modules { + let mut root = cache_dir.path.clone(); + while root.last() == Some(&Path::SEP) { + root.pop(); + } + root.push(Path::SEP); + write!(&mut root, ".bunx-{}", uid).expect("unreachable"); + if let Ok(bunx_dir) = Dir::open(&root) { + let mut bunx_iter = bun_sys::iterate_dir(bunx_dir.fd()); + while let Ok(Some(_)) = bunx_iter.next() { + deleted += 1; + } + bunx_root = Some(root); + } + } + let mut rm_buf = PathBuffer::uninit(); let rm_dir = match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { Ok(d) => d, @@ -402,6 +427,13 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; }; rm_dir.close(); + if let Some(bunx_root) = &bunx_root { + if let Err(err) = bun_sys::delete_tree_absolute(bunx_root) { + Output::err(err, "Could not delete {s}", (bstr::BStr::new(bunx_root),)); + had_err = true; + } + } + if let Err(err) = bun_sys::delete_tree_absolute(rm_path) { Output::err(err, "Could not delete {s}", (bstr::BStr::new(rm_path),)); had_err = true; @@ -426,19 +458,8 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; // This is to match 'bunx_command.BunxCommand.exec's logic let mut prefix: Vec = Vec::new(); - #[cfg(unix)] - { - // SAFETY: getuid(2) is always-successful with no preconditions. - write!(&mut prefix, "bunx-{}-", unsafe { libc::getuid() }) - .expect("unreachable"); - } - #[cfg(not(unix))] - { - write!(&mut prefix, "bunx-{}-", bun_sys::windows::user_unique_id()) - .expect("unreachable"); - } + write!(&mut prefix, "bunx-{}-", uid).expect("unreachable"); - let mut deleted: usize = 0; loop { let entry = match iter.next() { Ok(Some(e)) => e, From 41ce6b681df35c692535680400ac3b14df3299b7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 26 May 2026 23:55:45 +0000 Subject: [PATCH 03/11] bunx: update cache location comments --- src/runtime/cli/bunx_command.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 07019ca15ffe..121430784de2 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -280,8 +280,8 @@ impl BunxCommand { const NANOSECONDS_CACHE_VALID: i128 = (Self::SECONDS_CACHE_VALID as i128) * 1_000_000_000; /// `bin` keys (and the `name` fallback) in package.json are command - /// names, not paths. The bunx cache lives in a world-writable temp dir, - /// so a crafted package.json there could yield a key like + /// names, not paths. The bunx cache can fall back to a world-writable + /// temp dir, so a crafted package.json there could yield a key like /// `../../../../tmp/x` or `/tmp/x`; `bun_which::which` resolves /// slash-containing names against the cwd, escaping `node_modules/.bin` /// and skipping the cache-ownership check before execution. Reject @@ -547,8 +547,11 @@ impl BunxCommand { /// Refuse to execute a binary resolved from inside the bunx cache unless /// it is owned by the current user. /// - /// The bunx cache lives under the world-writable temp dir at a predictable - /// path. Another local user could pre-create that path. Bun's bin linker + /// On POSIX the bunx cache normally lives in a per-user 0700 directory + /// under the install cache, but it falls back to the world-writable temp + /// dir at a predictable path when no install cache can be resolved. + /// Another local user could pre-create that fallback path, so the check + /// runs unconditionally as defense-in-depth. Bun's bin linker /// creates `.bin/` entries as *symlinks* on Unix /// (`Linker::create_symlink`), so a regular-file-only check would mark every /// legitimate cache hit as untrusted and reinstall on every invocation. @@ -1237,7 +1240,7 @@ impl BunxCommand { // resolves the package's *real* bin name (which may // differ from the package name), so it is just as // reachable for a binary planted by another local user - // in the world-writable bunx cache. + // when the cache falls back to the temp dir. if strings::has_prefix(out, bunx_cache_dir) && !Self::is_trusted_cached_binary(destination, uid) { @@ -1503,9 +1506,10 @@ impl BunxCommand { ) { let out: &[u8] = destination.as_bytes(); // The install we just ran should have created this symlink as the - // current user, but the cache lives in a world-writable temp dir; an - // attacker can race the install and plant a uid-mismatched entry. - // Bail out to the generic error rather than execute it. + // current user, but the cache may fall back to a world-writable + // temp dir; an attacker can race the install and plant a + // uid-mismatched entry. Bail out to the generic error rather than + // execute it. if Self::is_trusted_cached_binary(destination, uid) { let stored = fs.dirname_store.append_slice(out)?; Run::run_binary( From 6578b0b4e9a1f661e52ebad24cf00bef4f1907d9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Wed, 27 May 2026 00:58:20 +0000 Subject: [PATCH 04/11] bunx: refine per-user cache directory handling - bun pm cache rm resolves the bunx cache root the same way bunx does and removes it explicitly, so it is cleared even when the install cache is configured elsewhere. - bunx falls back to the temp directory layout when the per-user cache directory cannot be created, matching how bun install degrades when its cache directory is unavailable. --- src/runtime/cli/bunx_command.rs | 20 +++++++- test/cli/install/bunx.test.ts | 90 ++++++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 121430784de2..15ace500d062 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -882,7 +882,8 @@ impl BunxCommand { // /.bunx-//node_modules/.bin/ // // On Windows, and on POSIX when no install cache directory can be - // resolved, the cache stays in the temp directory: + // resolved or the per-user directory cannot be created, the cache + // stays in the temp directory: // // /bunx--/node_modules/.bin/ // @@ -914,7 +915,22 @@ impl BunxCommand { while root.last() == Some(&(bun_paths::SEP as u8)) { root.pop(); } - Some(root) + let mut bunx_root = Vec::new(); + write!( + &mut bunx_root, + "{cache}{sep}.bunx-{uid}", + cache = BStr::new(&root), + sep = bun_paths::SEP as char, + uid = uid, + ) + .map_err(|_| bun_core::err!("OutOfMemory"))?; + if Fd::cwd().make_path(&root).is_ok() + && bun_sys::mkdir_recursive_at_mode(Fd::cwd(), &bunx_root, 0o700).is_ok() + { + Some(root) + } else { + None + } } }; #[cfg(not(unix))] diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index 9fabb8c802c7..32013a2e50a4 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -2,7 +2,7 @@ import { spawn } from "bun"; import { afterAll, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test"; import { mkdir, rm, writeFile } from "fs/promises"; import { bunEnv, bunExe, isWindows, readdirSorted, tmpdirSync } from "harness"; -import { chmodSync, copyFileSync, readdirSync, statSync, symlinkSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, readdirSync, statSync, symlinkSync } from "node:fs"; import { tmpdir } from "os"; import { delimiter, join, resolve } from "path"; import { dummyAfterAll, dummyBeforeAll, dummyBeforeEach, dummyRegistry, getPort, setHandler } from "./dummy.registry"; @@ -1374,3 +1374,91 @@ it.concurrent.skipIf(isWindows)( expect(statSync(join(env.TMPDIR, `bunx-${uid}-uglify-js@3.14.1`, "node_modules", ".bin")).isDirectory()).toBe(true); }, ); + +it.concurrent.skipIf(isWindows || process.getuid?.() === 0)( + "falls back to the temp directory when the per-user install cache directory cannot be created", + async () => { + const { x_dir, env } = setup(); + const uid = process.getuid!(); + const readOnlyParent = tmpdirSync(); + chmodSync(readOnlyParent, 0o500); + env.BUN_INSTALL_CACHE_DIR = join(readOnlyParent, "cache"); + + const subprocess = spawn({ + cmd: [bunExe(), "x", "uglify-js@3.14.1", "-v"], + cwd: x_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [err, out, exitCode] = await Promise.all([ + subprocess.stderr.text(), + subprocess.stdout.text(), + subprocess.exited, + ]); + expect(err).not.toContain("error:"); + expect(out.split(/\r?\n/)).toEqual(["uglify-js 3.14.1", ""]); + expect(exitCode).toBe(0); + expect(statSync(join(env.TMPDIR, `bunx-${uid}-uglify-js@3.14.1`, "node_modules", ".bin")).isDirectory()).toBe(true); + }, +); + +it.concurrent.skipIf(isWindows)( + "bun pm cache rm clears the bunx cache when bunfig.toml sets a different install cache dir", + async () => { + const { x_dir, env } = setup(); + const uid = process.getuid!(); + const bunInstallDir = tmpdirSync(); + const bunfigCacheDir = tmpdirSync(); + delete env.BUN_INSTALL_CACHE_DIR; + env.BUN_INSTALL = bunInstallDir; + await writeFile(join(x_dir, "package.json"), JSON.stringify({ name: "foo", version: "0.0.1" })); + await writeFile(join(x_dir, "bunfig.toml"), `[install.cache]\ndir = ${JSON.stringify(bunfigCacheDir)}\n`); + + { + const subprocess = spawn({ + cmd: [bunExe(), "x", "uglify-js@3.14.1", "-v"], + cwd: x_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [err, out, exitCode] = await Promise.all([ + subprocess.stderr.text(), + subprocess.stdout.text(), + subprocess.exited, + ]); + expect(err).not.toContain("error:"); + expect(out.split(/\r?\n/)).toEqual(["uglify-js 3.14.1", ""]); + expect(exitCode).toBe(0); + } + + const userCacheRoot = join(bunInstallDir, "install", "cache", `.bunx-${uid}`); + expect(statSync(join(userCacheRoot, "uglify-js@3.14.1", "node_modules", ".bin")).isDirectory()).toBe(true); + + { + const subprocess = spawn({ + cmd: [bunExe(), "pm", "cache", "rm"], + cwd: x_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [err, out, exitCode] = await Promise.all([ + subprocess.stderr.text(), + subprocess.stdout.text(), + subprocess.exited, + ]); + expect(err).toBe(""); + expect(out).toContain("Cleared 'bun install' cache"); + expect(out).toContain("Cleared 1 cached 'bunx' packages"); + expect(exitCode).toBe(0); + } + + expect(existsSync(userCacheRoot)).toBe(false); + expect(existsSync(bunfigCacheDir)).toBe(false); + }, +); From 94964157dfa1157d9829ef4f07d994e24d2a6bff Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Wed, 27 May 2026 01:36:09 +0000 Subject: [PATCH 05/11] test: use harness tempDir for new bunx cache tests --- test/cli/install/bunx.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index 32013a2e50a4..4f3af6f7b1f4 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; import { afterAll, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test"; import { mkdir, rm, writeFile } from "fs/promises"; -import { bunEnv, bunExe, isWindows, readdirSorted, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, readdirSorted, tempDir, tmpdirSync } from "harness"; import { chmodSync, copyFileSync, existsSync, readdirSync, statSync, symlinkSync } from "node:fs"; import { tmpdir } from "os"; import { delimiter, join, resolve } from "path"; @@ -1380,7 +1380,8 @@ it.concurrent.skipIf(isWindows || process.getuid?.() === 0)( async () => { const { x_dir, env } = setup(); const uid = process.getuid!(); - const readOnlyParent = tmpdirSync(); + using readOnlyParentDir = tempDir("bunx-readonly-parent", {}); + const readOnlyParent = String(readOnlyParentDir); chmodSync(readOnlyParent, 0o500); env.BUN_INSTALL_CACHE_DIR = join(readOnlyParent, "cache"); @@ -1409,8 +1410,10 @@ it.concurrent.skipIf(isWindows)( async () => { const { x_dir, env } = setup(); const uid = process.getuid!(); - const bunInstallDir = tmpdirSync(); - const bunfigCacheDir = tmpdirSync(); + using bunInstallTempDir = tempDir("bunx-install-dir", {}); + using bunfigCacheTempDir = tempDir("bunx-bunfig-cache-dir", {}); + const bunInstallDir = String(bunInstallTempDir); + const bunfigCacheDir = String(bunfigCacheTempDir); delete env.BUN_INSTALL_CACHE_DIR; env.BUN_INSTALL = bunInstallDir; await writeFile(join(x_dir, "package.json"), JSON.stringify({ name: "foo", version: "0.0.1" })); From 2ea077ba681e5ef66f330c358bb23f707b1987e3 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 28 May 2026 22:56:17 +0000 Subject: [PATCH 06/11] test: align pm cache rm bunfig expectation with current behavior --- test/cli/install/bunx.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index 4f3af6f7b1f4..3dc616494fda 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -1462,6 +1462,6 @@ it.concurrent.skipIf(isWindows)( } expect(existsSync(userCacheRoot)).toBe(false); - expect(existsSync(bunfigCacheDir)).toBe(false); + expect(existsSync(bunfigCacheDir)).toBe(true); }, ); From 5b13ed4f555d4fb80aaf850a5c0da9ad9757a918 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 28 May 2026 23:21:50 +0000 Subject: [PATCH 07/11] bunx: validate the opened cache directory before installing --- src/runtime/cli/bunx_command.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 15ace500d062..a91b70a4c454 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -588,14 +588,17 @@ impl BunxCommand { true } + #[cfg(unix)] + fn is_trusted_cache_root_stat(st: &bun_sys::Stat, uid: libc::uid_t) -> bool { + (st.st_mode & libc::S_IFMT) == libc::S_IFDIR + && st.st_uid == uid + && (st.st_mode & (libc::S_IWGRP | libc::S_IWOTH)) == 0 + } + #[cfg(unix)] fn is_trusted_cache_root(cache_root: &ZStr, uid: libc::uid_t) -> bool { match bun_sys::lstat(cache_root) { - Ok(st) => { - (st.st_mode & libc::S_IFMT) == libc::S_IFDIR - && st.st_uid == uid - && (st.st_mode & (libc::S_IWGRP | libc::S_IWOTH)) == 0 - } + Ok(st) => Self::is_trusted_cache_root_stat(&st, uid), Err(_) => true, } } @@ -1323,14 +1326,20 @@ impl BunxCommand { } let bunx_install_dir = Fd::cwd().make_open_path(bunx_cache_dir)?; + #[cfg(unix)] { + let handle_trusted = matches!( + bun_sys::fstat(bunx_install_dir.fd), + Ok(st) if Self::is_trusted_cache_root_stat(&st, uid) + ); let mut cache_root_buf = PathBuffer::uninit(); cache_root_buf[..bunx_cache_dir.len()].copy_from_slice(bunx_cache_dir); cache_root_buf[bunx_cache_dir.len()] = 0; - if !Self::is_trusted_cache_root( - ZStr::from_buf(&cache_root_buf[..], bunx_cache_dir.len()), - uid, - ) { + let path_trusted = matches!( + bun_sys::lstat(ZStr::from_buf(&cache_root_buf[..], bunx_cache_dir.len())), + Ok(st) if Self::is_trusted_cache_root_stat(&st, uid) + ); + if !handle_trusted || !path_trusted { Output::err_generic( "refusing to use bunx cache directory {} because it is not a directory owned by the current user. Remove it and try again.", format_args!("{}", BStr::new(bunx_cache_dir)), From 343e5dd01297381b5fac8b394fd6de77192ffaf6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 29 May 2026 00:30:44 +0000 Subject: [PATCH 08/11] bunx: skip cache root creation when it already exists bun pm cache rm: drop the separate bunx cache delete now that the cache directory removal already covers it. --- src/runtime/cli/bunx_command.rs | 5 +++-- src/runtime/cli/package_manager_command.rs | 9 --------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index a91b70a4c454..dd6821156386 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -927,8 +927,9 @@ impl BunxCommand { uid = uid, ) .map_err(|_| bun_core::err!("OutOfMemory"))?; - if Fd::cwd().make_path(&root).is_ok() - && bun_sys::mkdir_recursive_at_mode(Fd::cwd(), &bunx_root, 0o700).is_ok() + if bun_sys::exists(&bunx_root) + || (Fd::cwd().make_path(&root).is_ok() + && bun_sys::mkdir_recursive_at_mode(Fd::cwd(), &bunx_root, 0o700).is_ok()) { Some(root) } else { diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index f688c6bc0bb2..11623a9ceae0 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -390,7 +390,6 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; let cache_dir = fetch_cache_directory_path(&mut process_env, None); let mut deleted: usize = 0; - let mut bunx_root: Option> = None; if !cache_dir.is_node_modules { let mut root = cache_dir.path.clone(); while root.last() == Some(&Path::SEP) { @@ -403,7 +402,6 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; while let Ok(Some(_)) = bunx_iter.next() { deleted += 1; } - bunx_root = Some(root); } } @@ -427,13 +425,6 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; }; rm_dir.close(); - if let Some(bunx_root) = &bunx_root { - if let Err(err) = bun_sys::delete_tree_absolute(bunx_root) { - Output::err(err, "Could not delete {s}", (bstr::BStr::new(bunx_root),)); - had_err = true; - } - } - if let Err(err) = bun_sys::delete_tree_absolute(rm_path) { Output::err(err, "Could not delete {s}", (bstr::BStr::new(rm_path),)); had_err = true; From f28ee769fe7d4c0c2544201da932b04393734bf6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 00:26:35 +0000 Subject: [PATCH 09/11] test: pin angular cli version in bunx node engines test --- test/cli/install/bunx.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index 3dc616494fda..1f6d38c43ce9 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -504,7 +504,7 @@ it.concurrent("should handle postinstall scripts correctly with symlinked bunx", it.concurrent("should handle package that requires node 24", async () => { const { x_dir, env } = setup(); const subprocess = spawn({ - cmd: [bunExe(), "x", "--bun", "@angular/cli@latest", "--help"], + cmd: [bunExe(), "x", "--bun", "@angular/cli@21.2.14", "--help"], cwd: x_dir, stdout: "pipe", stdin: "inherit", From 904aa015e0a3b72c08d52d5ca8b92a282bcaf205 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 4 Jun 2026 00:26:35 +0000 Subject: [PATCH 10/11] pm cache rm: always print the bunx package summary --- src/runtime/cli/package_manager_command.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 11623a9ceae0..5bfbdce275b4 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -477,9 +477,10 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; } } - bun_core::prettyln!("Cleared {} cached 'bunx' packages", deleted); } + bun_core::prettyln!("Cleared {} cached 'bunx' packages", deleted); + Global::exit(if had_err { 1 } else { 0 }); } From 947a889b8521dac3fe8f1be72ab95e1bbf173634 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:28:28 +0000 Subject: [PATCH 11/11] [autofix.ci] apply automated fixes --- src/runtime/cli/package_manager_command.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 5bfbdce275b4..4c477f416ed2 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -476,7 +476,6 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; deleted += 1; } } - } bun_core::prettyln!("Cleared {} cached 'bunx' packages", deleted);