From 2bcdc36748d14560d86466d2458b81a198b6bef3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:38:05 +0000 Subject: [PATCH 1/7] runtime transpiler cache: per-uid root, ownership check, input-keyed section hashes The on-disk transpiler cache trusted its root directory and its own self-attesting hash fields: - The default cache path (@t@ under XDG_CACHE_HOME / ~/Library/Caches / ~/.bun/install/cache) was the same for every uid on a host, so a second local user could pre-populate it. - output_hash != 0 && hash(bytes) != output_hash let a planted entry set output_hash = 0 and skip verification; esm_record_hash had the same bypass and sourcemap_hash was never checked on load. - The section hashes used the fixed SEED, so a forger could recompute them over tampered bytes. - The resolved cache root was never lstat'd. This change: - namespaces the default leaf as @t@- (unix uid, or the user-name hash on Windows); an explicit BUN_RUNTIME_TRANSPILER_CACHE_PATH is still used verbatim, - lstat's the root once per process in get_cache_dir: a root that exists but is not a directory owned by the current uid with no group/other write bits disables the cache; only ENOENT is treated as benign, - seeds output / sourcemap / esm_record hashes with input_hash and verifies all three unconditionally; bumps EXPECTED_VERSION to 26. The hash is still unkeyed, so a same-uid writer who can read the source can recompute it; the uid namespacing and ownership check are the boundary. The module-record corruption test recomputes the keyed hash so it still reaches the deserializer. --- src/jsc/RuntimeTranspilerCache.rs | 101 +++++++++++++++++++------- test/cli/run/transpiler-cache.test.ts | 91 +++++++++++++++++++++-- 2 files changed, 159 insertions(+), 33 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 30345f1578a6..d8b09b2889ac 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,7 +51,9 @@ bun_core::declare_scope!(cache, visible); /// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot /// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's /// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type). -const EXPECTED_VERSION: u32 = 25; +/// Version 26: section hashes are seeded with `input_hash` (not the fixed +/// `SEED`) and a stored hash of 0 no longer skips verification. +const EXPECTED_VERSION: u32 = 26; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a @@ -336,11 +338,9 @@ impl Entry { ..Default::default() }; - metadata.output_hash = hash(output_bytes); - metadata.sourcemap_hash = hash(sourcemap); - if !esm_record.is_empty() { - metadata.esm_record_hash = hash(esm_record); - } + metadata.output_hash = Wyhash::hash(input_hash, output_bytes); + metadata.sourcemap_hash = Wyhash::hash(input_hash, sourcemap); + metadata.esm_record_hash = Wyhash::hash(input_hash, esm_record); let mut metadata_stream = bun_io::FixedBufferStream::new_mut(&mut metadata_buf[..]); metadata.encode(&mut metadata_stream)?; @@ -432,6 +432,8 @@ impl Entry { return Err(crate::CrateError::MissingData); } + let section_seed = self.metadata.input_hash; + debug_assert!( matches!(&self.output_code, OutputCode::Utf8(b) if b.is_empty()), "this should be the default value" @@ -474,7 +476,7 @@ impl Entry { return Err(crate::CrateError::MissingData); } - if self.metadata.output_hash != 0 && hash(bytes) != self.metadata.output_hash { + if Wyhash::hash(section_seed, bytes) != self.metadata.output_hash { return Err(crate::CrateError::InvalidHash); } @@ -503,17 +505,14 @@ impl Entry { // errdefer latin1.deref() — BunString is `Copy`, so guard explicitly. let errdefer = scopeguard::guard(latin1, |s| s.deref()); let read_bytes = file.pread_all(bytes, self.metadata.output_byte_offset)?; - - if self.metadata.output_hash != 0 { - if hash(latin1.latin1()) != self.metadata.output_hash { - return Err(crate::CrateError::InvalidHash); - } - } - if read_bytes as u64 != self.metadata.output_byte_length { return Err(crate::CrateError::MissingData); } + if Wyhash::hash(section_seed, latin1.latin1()) != self.metadata.output_hash { + return Err(crate::CrateError::InvalidHash); + } + scopeguard::ScopeGuard::into_inner(errdefer); OutputCode::String(latin1) } @@ -537,11 +536,9 @@ impl Entry { return Err(crate::CrateError::MissingData); } - if self.metadata.output_hash != 0 { - let utf16_bytes: &[u8] = bytemuck::cast_slice(string.utf16()); - if hash(utf16_bytes) != self.metadata.output_hash { - return Err(crate::CrateError::InvalidHash); - } + let utf16_bytes: &[u8] = bytemuck::cast_slice(string.utf16()); + if Wyhash::hash(section_seed, utf16_bytes) != self.metadata.output_hash { + return Err(crate::CrateError::InvalidHash); } scopeguard::ScopeGuard::into_inner(errdefer); @@ -562,6 +559,9 @@ impl Entry { self.metadata.sourcemap_byte_length as usize, self.metadata.sourcemap_byte_offset, )?; + if Wyhash::hash(section_seed, &self.sourcemap) != self.metadata.sourcemap_hash { + return Err(crate::CrateError::InvalidHash); + } } if self.metadata.esm_record_byte_length > 0 { @@ -571,10 +571,8 @@ impl Entry { self.metadata.esm_record_byte_offset, )?; - if self.metadata.esm_record_hash != 0 { - if hash(&esm_record) != self.metadata.esm_record_hash { - return Err(crate::CrateError::InvalidHash); - } + if Wyhash::hash(section_seed, &esm_record) != self.metadata.esm_record_hash { + return Err(crate::CrateError::InvalidHash); } self.esm_record = esm_record; @@ -613,6 +611,39 @@ pub(crate) fn hash(bytes: &[u8]) -> u64 { Wyhash::hash(SEED, bytes) } +#[cfg(unix)] +#[inline] +fn current_user_id() -> u32 { + bun_sys::c::getuid() as u32 +} + +#[cfg(windows)] +#[inline] +fn current_user_id() -> u32 { + bun_sys::windows::user_unique_id() +} + +/// `true` when `path` lstat's as a directory owned by the current uid with no +/// group/other write bits, or does not exist. Any other result fails closed. +#[cfg(unix)] +fn is_trusted_cache_root(path: &ZStr) -> bool { + match sys::lstat(path) { + Ok(st) => { + (st.st_mode & libc::S_IFMT) == libc::S_IFDIR + && st.st_uid == bun_sys::c::getuid() + && (st.st_mode & (libc::S_IWGRP | libc::S_IWOTH)) == 0 + } + Err(e) if e.get_errno() == sys::E::ENOENT => true, + Err(_) => false, + } +} + +#[cfg(not(unix))] +#[inline(always)] +fn is_trusted_cache_root(_path: &ZStr) -> bool { + true +} + /// Allocate `len` bytes and fill them via `pread_all` at `offset`, returning /// `MissingData` on a short read. /// @@ -702,8 +733,13 @@ impl RuntimeTranspilerCache { // that `absBufZ` used. let top = FileSystem::instance().top_level_dir; + let mut seg = [0u8; 4 + 10]; + seg[..4].copy_from_slice(b"@t@-"); + let n = bun_core::fmt::print_int(&mut seg[4..], current_user_id()); + let tcache_seg: &[u8] = &seg[..4 + n]; + if let Some(dir) = env_var::XDG_CACHE_HOME.get() { - let parts: &[&[u8]] = &[dir, b"bun", b"@t@"]; + let parts: &[&[u8]] = &[dir, b"bun", tcache_seg]; return path_handler::join_abs_string_buf_z::( top, &mut buf[..], @@ -717,7 +753,7 @@ impl RuntimeTranspilerCache { // On a mac, default to ~/Library/Caches/bun/* // This is different than ~/.bun/install/cache, and not configurable by the user. if let Some(home) = env_var::HOME.get() { - let parts: &[&[u8]] = &[home, b"Library/", b"Caches/", b"bun", b"@t@"]; + let parts: &[&[u8]] = &[home, b"Library/", b"Caches/", b"bun", tcache_seg]; return path_handler::join_abs_string_buf_z::( top, &mut buf[..], @@ -728,7 +764,7 @@ impl RuntimeTranspilerCache { } if let Some(dir) = env_var::HOME.get() { - let parts: &[&[u8]] = &[dir, b".bun", b"install", b"cache", b"@t@"]; + let parts: &[&[u8]] = &[dir, b".bun", b"install", b"cache", tcache_seg]; return path_handler::join_abs_string_buf_z::( top, &mut buf[..], @@ -760,8 +796,17 @@ impl RuntimeTranspilerCache { let path_len = match Self::RUNTIME_TRANSPILER_CACHE.with(|c| c.get()) { Some(len) => len, None => { - let len = Self::CACHE_DIR_BUF - .with_borrow_mut(|tl_buf| Self::really_get_cache_dir(tl_buf)); + let len = Self::CACHE_DIR_BUF.with_borrow_mut(|tl_buf| { + let len = Self::really_get_cache_dir(tl_buf); + if len > 0 && !is_trusted_cache_root(ZStr::from_buf(&tl_buf[..], len)) { + bun_core::scoped_log!( + cache, + "transpiler cache root failed ownership/mode check, disabling" + ); + return 0; + } + len + }); if len == 0 { IS_DISABLED.store(true, Ordering::Relaxed); return Err(crate::CrateError::CacheDisabled); diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 01d569bb15ab..53ffeebf23df 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -1,7 +1,7 @@ import { Subprocess } from "bun"; import { beforeEach, describe, expect, test } from "bun:test"; import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, bunRun, tmpdirSync } from "harness"; +import { bunEnv, bunExe, bunRun, isPosix, tmpdirSync } from "harness"; import { join } from "path"; function dummyFile(size: number, cache_bust: string, value: string | { code: string }) { @@ -292,6 +292,84 @@ describe("transpiler cache", () => { expect(newCacheCount()).toBe(0); }); }); + + test("rejects a cache entry whose output was rewritten with a zeroed output_hash", async () => { + // Cache entry header (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode): + // 0: cache_version u32, 4: module_type u8, 5: output_encoding u8, + // 6: features_hash u64, 14: input_byte_length u64, 22: input_hash u64, + // 30: output_byte_offset u64, 38: output_byte_length u64, + // 46: output_hash u64, ...; payload follows the 102-byte header. + const OUTPUT_BYTE_OFFSET = 30; + const OUTPUT_BYTE_LENGTH = 38; + const OUTPUT_HASH = 46; + + writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "forge", "GOOD")); + + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("GOOD"); + const entries = readdirSync(cache_dir); + expect(entries.length).toBe(1); + + // Tamper: rewrite the printed literal inside the stored output and clear + // output_hash so the (forgeable) self-check would have accepted it. + const pile = join(cache_dir, entries[0]); + const buf = Buffer.from(readFileSync(pile)); + const outOff = Number(buf.readBigUInt64LE(OUTPUT_BYTE_OFFSET)); + const outLen = Number(buf.readBigUInt64LE(OUTPUT_BYTE_LENGTH)); + const region = buf.subarray(outOff, outOff + outLen); + const needle = region.indexOf("GOOD"); + expect(needle).toBeGreaterThanOrEqual(0); + region.write("EVIL", needle, "latin1"); + buf.writeBigUInt64LE(0n, OUTPUT_HASH); + writeFileSync(pile, buf); + + // The tampered entry must be rejected and the source re-transpiled: the + // original output is observed and a fresh entry replaces the forged one. + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("GOOD"); + const after = readdirSync(cache_dir); + expect(after.length).toBe(1); + expect(Buffer.from(readFileSync(join(cache_dir, after[0]))).readBigUInt64LE(OUTPUT_HASH)).not.toBe(0n); + }); + + test("rejects a cache entry whose output_hash was recomputed with the fixed seed", async () => { + const OUTPUT_BYTE_OFFSET = 30; + const OUTPUT_BYTE_LENGTH = 38; + const OUTPUT_HASH = 46; + + writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "forge2", "GOOD")); + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("GOOD"); + const entries = readdirSync(cache_dir); + expect(entries.length).toBe(1); + + // Section hashes are keyed on the per-entry input hash, not the fixed + // seed, so a wyhash(seed=42) over the tampered output must still be + // rejected. + const pile = join(cache_dir, entries[0]); + const buf = Buffer.from(readFileSync(pile)); + const outOff = Number(buf.readBigUInt64LE(OUTPUT_BYTE_OFFSET)); + const outLen = Number(buf.readBigUInt64LE(OUTPUT_BYTE_LENGTH)); + const region = buf.subarray(outOff, outOff + outLen); + const needle = region.indexOf("GOOD"); + expect(needle).toBeGreaterThanOrEqual(0); + region.write("EVIL", needle, "latin1"); + buf.writeBigUInt64LE(Bun.hash.wyhash(region, 42n), OUTPUT_HASH); + writeFileSync(pile, buf); + + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("GOOD"); + }); + + test.skipIf(!isPosix)("disables the cache when the cache root is writable by other users", async () => { + // An attacker-controlled cache root (group/other-writable) must not be + // read from or written to: the transpiler cache is disabled for the run. + mkdirSync(cache_dir, { recursive: true }); + chmodSync(cache_dir, 0o777); + try { + writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "trust", "ok")); + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("ok"); + expect(readdirSync(cache_dir)).toEqual([]); + } finally { + chmodSync(cache_dir, 0o755); + } + }); }); test("rejects cached module records containing out-of-range string indices", () => { @@ -310,6 +388,7 @@ test("rejects cached module records containing out-of-range string indices", () // serialize()): // [record_kinds_len u32][record_kinds, 1 byte each][pad to 4] // [buffer_len u32][buffer: u32 string index x buffer_len] ... + const INPUT_HASH_AT = 22; const ESM_RECORD_BYTE_OFFSET_AT = 78; const ESM_RECORD_BYTE_LENGTH_AT = 86; const ESM_RECORD_HASH_AT = 94; @@ -334,10 +413,12 @@ test("rejects cached module records containing out-of-range string indices", () for (let i = 0; i < bufferLen; i++) { data.writeUInt32LE(0x7fffffff, off + i * 4); } - // The cache loader skips esm-record content verification when the stored - // hash field is zero, so whoever writes the cache file controls exactly - // what reaches the module record deserializer. - data.writeBigUInt64LE(0n, ESM_RECORD_HASH_AT); + // Section hashes are keyed on the input hash; recompute it for the + // rewritten record so the entry passes the loader's hash check and the + // corrupted indices reach the module-record deserializer under test. + const inputHash = data.readBigUInt64LE(INPUT_HASH_AT); + const esmHash = Bun.hash.wyhash(data.subarray(esmOff, esmOff + esmLen), inputHash); + data.writeBigUInt64LE(esmHash, ESM_RECORD_HASH_AT); writeFileSync(file, data); return true; } From 497d1c95a1a2633e646c3db030cb6d504c53bc2e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:47:29 +0000 Subject: [PATCH 2/7] transpiler cache: fstat the opened directory before each write get_cache_dir validates the root by path once per process. to_file opens the directory again later, after transpilation, so a root that appeared or changed in between would otherwise receive the entry. Re-check the same predicate on the fd we actually opened and disable the cache if it fails. Our own mkdir uses 0755, so a root we create always passes. --- src/jsc/RuntimeTranspilerCache.rs | 31 ++++++++++++++++++++++----- test/cli/run/transpiler-cache.test.ts | 21 ++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index d8b09b2889ac..f76b188a2d24 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -623,27 +623,43 @@ fn current_user_id() -> u32 { bun_sys::windows::user_unique_id() } +#[cfg(unix)] +fn is_trusted_dir_stat(st: &sys::Stat) -> bool { + (st.st_mode & libc::S_IFMT) == libc::S_IFDIR + && st.st_uid == bun_sys::c::getuid() + && (st.st_mode & (libc::S_IWGRP | libc::S_IWOTH)) == 0 +} + /// `true` when `path` lstat's as a directory owned by the current uid with no /// group/other write bits, or does not exist. Any other result fails closed. #[cfg(unix)] fn is_trusted_cache_root(path: &ZStr) -> bool { match sys::lstat(path) { - Ok(st) => { - (st.st_mode & libc::S_IFMT) == libc::S_IFDIR - && st.st_uid == bun_sys::c::getuid() - && (st.st_mode & (libc::S_IWGRP | libc::S_IWOTH)) == 0 - } + Ok(st) => is_trusted_dir_stat(&st), Err(e) if e.get_errno() == sys::E::ENOENT => true, Err(_) => false, } } +/// Re-checks the directory we actually opened for writing, so a root created +/// between `get_cache_dir`'s lstat and this open cannot receive our entries. +#[cfg(unix)] +fn is_trusted_opened_cache_dir(fd: Fd) -> bool { + sys::fstat(fd).is_ok_and(|st| is_trusted_dir_stat(&st)) +} + #[cfg(not(unix))] #[inline(always)] fn is_trusted_cache_root(_path: &ZStr) -> bool { true } +#[cfg(not(unix))] +#[inline(always)] +fn is_trusted_opened_cache_dir(_fd: Fd) -> bool { + true +} + /// Allocate `len` bytes and fill them via `pread_all` at `offset`, returning /// `MissingData` on a short read. /// @@ -944,6 +960,11 @@ impl RuntimeTranspilerCache { } }); + if cache_dir_fd != Fd::cwd() && !is_trusted_opened_cache_dir(cache_dir_fd) { + IS_DISABLED.store(true, Ordering::Relaxed); + return Err(crate::CrateError::CacheDisabled); + } + Entry::save( cache_dir_fd, cache_file_path, diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 53ffeebf23df..d75195d089ca 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -370,6 +370,27 @@ describe("transpiler cache", () => { chmodSync(cache_dir, 0o755); } }); + + test.skipIf(!isPosix)("stops writing when the cache root turns untrusted after the initial check", async () => { + // The root is validated once per process when it is first resolved. The + // directory actually opened for each write is re-validated, so a root that + // becomes group/other-writable later in the same process receives no + // further entries. + const filler = "\n//" + Buffer.alloc(50 * 1024, "f").toString(); + writeFileSync( + join(temp_dir, "a.js"), + `require("fs").chmodSync(${JSON.stringify(cache_dir)}, 0o777); +require("./b.js");${filler}`, + ); + writeFileSync(join(temp_dir, "b.js"), dummyFile(50 * 1024, "late", "late-ok")); + try { + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("late-ok"); + // Only a.js (written while the root was still trusted) is cached. + expect(readdirSync(cache_dir).length).toBe(1); + } finally { + chmodSync(cache_dir, 0o755); + } + }); }); test("rejects cached module records containing out-of-range string indices", () => { From 0d0da21468021db8b82f0c3ba03412206c25303b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:49:59 +0000 Subject: [PATCH 3/7] shorten rustdoc on is_trusted_opened_cache_dir --- src/jsc/RuntimeTranspilerCache.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index f76b188a2d24..79fdc0e51cad 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -641,8 +641,7 @@ fn is_trusted_cache_root(path: &ZStr) -> bool { } } -/// Re-checks the directory we actually opened for writing, so a root created -/// between `get_cache_dir`'s lstat and this open cannot receive our entries. +/// Same predicate as `is_trusted_cache_root`, on the directory we opened. #[cfg(unix)] fn is_trusted_opened_cache_dir(fd: Fd) -> bool { sys::fstat(fd).is_ok_and(|st| is_trusted_dir_stat(&st)) From 7d453d61c6c110ae1d56bf9363b31e1a798670dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:10:35 +0000 Subject: [PATCH 4/7] test: assert on the shared parent dir, not the old @t@ leaf --- test/cli/run/transpiler-cache.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index d75195d089ca..300327440f4c 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -140,8 +140,8 @@ describe("transpiler cache", () => { // Stand-in for the shared, world-writable system temp dir. Pre-create // bun/@t@ inside it the way another local user could on a multi-user host. const shared_tmp = join(temp_dir, "shared-tmp"); - const shared_cache = join(shared_tmp, "bun", "@t@"); - mkdirSync(shared_cache, { recursive: true }); + const shared_bun = join(shared_tmp, "bun"); + mkdirSync(join(shared_bun, "@t@"), { recursive: true }); // No per-user cache location is available (no BUN_RUNTIME_TRANSPILER_CACHE_PATH, // no XDG_CACHE_HOME, no HOME) — the only remaining candidate is the shared @@ -160,9 +160,10 @@ describe("transpiler cache", () => { }), ).toSpawn("no-tmpdir-cache"); - // No cache entry may be written into (or read back from) a directory that - // another local user could own and pre-populate. - expect(readdirSync(shared_cache)).toEqual([]); + // Nothing may be written under the shared dir, whatever leaf name a + // fallback would pick: only the pre-created decoy remains, and it is empty. + expect(readdirSync(shared_bun)).toEqual(["@t@"]); + expect(readdirSync(join(shared_bun, "@t@"))).toEqual([]); // A per-user cache location still works. expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("no-tmpdir-cache"); From 6a88f14ec8471b893c378dee4f33e9f0fcde13a8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:32:27 +0000 Subject: [PATCH 5/7] test: pin the pre-created cache root to 0755 so the ownership check is umask-independent --- test/cli/run/transpiler-cache.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 300327440f4c..8be3d530dd1e 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -170,7 +170,7 @@ describe("transpiler cache", () => { expect(newCacheCount()).toBe(1); }); test("works if the cache is not user-readable", async () => { - mkdirSync(cache_dir, { recursive: true }); + mkdirSync(cache_dir, { recursive: true, mode: 0o755 }); writeFileSync(join(temp_dir, "a.js"), dummyFile((50 * 1024 * 1.5) | 0, "1", "b")); expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("b"); expect(newCacheCount()).toBe(1); From 55e264dc51e5f191fe197b96d8abc289b960fc74 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:42:48 +0000 Subject: [PATCH 6/7] transpiler cache: key the per-user root and ownership check on the effective uid File access is checked against the effective uid, so that is the identity the cache path segment and the st_uid comparison must use. With the real uid, a process whose effective uid differs (a setuid wrapper, or a daemon that dropped privileges before exec) would either consume a directory owned by the unprivileged real uid while running with the privileged effective uid, or fail to use its own cache. Declares geteuid next to getuid in bun_sys::c. --- src/jsc/RuntimeTranspilerCache.rs | 6 ++++-- src/sys/lib.rs | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 79fdc0e51cad..f141397eee76 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -611,10 +611,12 @@ pub(crate) fn hash(bytes: &[u8]) -> u64 { Wyhash::hash(SEED, bytes) } +/// The effective uid: it is what the kernel checks file access against, so it +/// is what the cache path and the ownership check must be keyed on. #[cfg(unix)] #[inline] fn current_user_id() -> u32 { - bun_sys::c::getuid() as u32 + bun_sys::c::geteuid() as u32 } #[cfg(windows)] @@ -626,7 +628,7 @@ fn current_user_id() -> u32 { #[cfg(unix)] fn is_trusted_dir_stat(st: &sys::Stat) -> bool { (st.st_mode & libc::S_IFMT) == libc::S_IFDIR - && st.st_uid == bun_sys::c::getuid() + && st.st_uid == bun_sys::c::geteuid() && (st.st_mode & (libc::S_IWGRP | libc::S_IWOTH)) == 0 } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index cd3d67c1feaa..722343d2cff4 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -4952,13 +4952,14 @@ pub mod c { use core::ffi::{c_char, c_void}; #[cfg(unix)] pub use libc::fchmod; - // `getuid`/`getgid` take no args and read kernel + // `getuid`/`geteuid`/`getgid` take no args and read kernel // process state — no preconditions, never fail. Declared locally as // `safe fn` (instead of re-exporting the `libc` crate's raw decls) so // callers need no per-site proof. #[cfg(unix)] unsafe extern "C" { pub safe fn getuid() -> libc::uid_t; + pub safe fn geteuid() -> libc::uid_t; pub safe fn getgid() -> libc::gid_t; } #[cfg(unix)] From 75cbcd1dd2ba6c7c834e6822a1ff80d40f34626e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:45:59 +0000 Subject: [PATCH 7/7] shorten rustdoc on current_user_id --- src/jsc/RuntimeTranspilerCache.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index f141397eee76..6a4ef2135b0a 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -611,8 +611,7 @@ pub(crate) fn hash(bytes: &[u8]) -> u64 { Wyhash::hash(SEED, bytes) } -/// The effective uid: it is what the kernel checks file access against, so it -/// is what the cache path and the ownership check must be keyed on. +/// Effective uid: the identity the kernel checks file access against. #[cfg(unix)] #[inline] fn current_user_id() -> u32 {