diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 41aeb5854d19..dd6821156386 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. @@ -585,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, } } @@ -872,7 +878,15 @@ 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 or the per-user directory cannot be created, the cache + // stays in the temp directory: // // /bunx--/node_modules/.bin/ // @@ -893,6 +907,64 @@ 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(); + } + 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 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 { + None + } + } + }; + #[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 +973,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 +988,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)); @@ -1193,7 +1260,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) { @@ -1253,8 +1320,35 @@ 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)?; + #[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; + 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)), + ); + Global::exit(1); + } + } + 'create_package_json: { // create package.json, but only if it doesn't exist let package_json = match bun_sys::File::create( @@ -1438,9 +1532,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( diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 1ad195c405dd..4c477f416ed2 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -376,12 +376,35 @@ 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; + 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; + } + } + } + let mut rm_buf = PathBuffer::uninit(); let rm_dir = match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { Ok(d) => d, @@ -426,19 +449,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, @@ -464,10 +476,10 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; deleted += 1; } } - - bun_core::prettyln!("Cleared {} cached 'bunx' packages", deleted); } + bun_core::prettyln!("Cleared {} cached 'bunx' packages", deleted); + Global::exit(if had_err { 1 } else { 0 }); } diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index cb99066c38c7..1f6d38c43ce9 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -1,8 +1,8 @@ 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 { 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"; import { dummyAfterAll, dummyBeforeAll, dummyBeforeEach, dummyRegistry, getPort, setHandler } from "./dummy.registry"; @@ -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", @@ -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,169 @@ 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); + }, +); + +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!(); + using readOnlyParentDir = tempDir("bunx-readonly-parent", {}); + const readOnlyParent = String(readOnlyParentDir); + 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!(); + 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" })); + 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(true); + }, +);