From c945b4e53ec308c7a609f1a1b444a2cff84d8363 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:15:26 +0000 Subject: [PATCH 1/4] transpiler cache: verify the whole entry before any field of it is used A cache entry header was acted on as written. With the output length zeroed, the module ran as an empty file and the entry stayed on disk. The sourcemap section was never checked against its hash, a flipped module type or encoding byte was accepted, the size check added the stored lengths with wrapping arithmetic, and a FIFO at the entry path blocked the open forever. The header now ends with a hash of the header fields (format version 26). The reader checks the version and that hash first, then requires the offsets and lengths to describe the file size exactly, using checked arithmetic, and then checks the hash of every section, also when a section is empty. The entry is opened with O_NONBLOCK on unix and anything that is not a regular file is rejected. A rejected entry is deleted and written again, as before. --- src/jsc/RuntimeTranspilerCache.rs | 271 ++++++++++++++------------ src/jsc/error.rs | 6 + test/cli/run/transpiler-cache.test.ts | 215 +++++++++++++++++--- 3 files changed, 345 insertions(+), 147 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 30345f1578a6..f853e37276ef 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -7,6 +7,7 @@ use bun_ast::ExportsKind; use bun_ast::Source; use bun_core::{FeatureFlags, env_var}; use bun_core::{String as BunString, ZStr}; +use bun_io::Write as _; use bun_js_parser::ParserOptions; use bun_paths::resolve_path::{self as path_handler, platform}; use bun_paths::{self as paths, MAX_PATH_BYTES, PathBuffer, SEP}; @@ -51,7 +52,11 @@ 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: The header ends with a hash of the header fields, and every +/// section hash is stored (and checked) even when the section is empty. A +/// damaged header used to be acted on as written: a zeroed output length ran +/// the module as an empty file. +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 @@ -141,9 +146,13 @@ impl Default for Metadata { impl Metadata { // 1×u32 + 2×u8 (enum reprs) + 12×u64 = 4 + 2 + 96 = 102 - pub(crate) const SIZE: usize = 4 + 1 + 1 + 12 * 8; + const FIELDS_SIZE: usize = 4 + 1 + 1 + 12 * 8; + /// The fields, then `hash()` of the encoded fields. + pub(crate) const SIZE: usize = Self::FIELDS_SIZE + 8; - pub(crate) fn encode(&self, writer: &mut W) -> crate::CrateResult<()> { + pub(crate) fn encode(&self, out: &mut [u8; Self::SIZE]) -> crate::CrateResult<()> { + let (fields, fields_hash) = out.split_at_mut(Self::FIELDS_SIZE); + let mut writer = bun_io::FixedBufferStream::new_mut(fields); writer.write_int_le::(self.cache_version)?; writer.write_int_le::(self.module_type as u8)?; writer.write_int_le::(self.output_encoding.0)?; @@ -164,65 +173,100 @@ impl Metadata { writer.write_int_le::(self.esm_record_byte_offset)?; writer.write_int_le::(self.esm_record_byte_length)?; writer.write_int_le::(self.esm_record_hash)?; + debug_assert!(writer.pos == Self::FIELDS_SIZE); + + fields_hash.copy_from_slice(&hash(fields).to_le_bytes()); Ok(()) } - /// Both call sites (`from_file_with_cache_file_path`, the debug round-trip - /// in `Entry::save`) drive this from a fixed buffer, so accept the concrete - /// `bun_io::FixedBufferStream` over a borrowed slice. - pub(crate) fn decode( - &mut self, - reader: &mut bun_io::FixedBufferStream<&[u8]>, - ) -> crate::CrateResult<()> { - self.cache_version = reader.read_int_le::()?; - if self.cache_version != EXPECTED_VERSION { + /// Decodes the header at the start of `bytes`. No field is returned to + /// the caller before the version and the header hash have been checked. + pub(crate) fn decode(bytes: &[u8]) -> crate::CrateResult { + let mut reader = bun_io::FixedBufferStream::new(bytes); + let cache_version = reader.read_int_le::()?; + if cache_version != EXPECTED_VERSION { return Err(crate::CrateError::StaleCache); } - // Validate the raw discriminants immediately so `ModuleType` never - // holds an out-of-range value. let module_type_raw = reader.read_int_le::()?; let output_encoding_raw = reader.read_int_le::()?; - self.features_hash = reader.read_int_le::()?; + let features_hash = reader.read_int_le::()?; + + let input_byte_length = reader.read_int_le::()?; + let input_hash = reader.read_int_le::()?; - self.input_byte_length = reader.read_int_le::()?; - self.input_hash = reader.read_int_le::()?; + let output_byte_offset = reader.read_int_le::()?; + let output_byte_length = reader.read_int_le::()?; + let output_hash = reader.read_int_le::()?; - self.output_byte_offset = reader.read_int_le::()?; - self.output_byte_length = reader.read_int_le::()?; - self.output_hash = reader.read_int_le::()?; + let sourcemap_byte_offset = reader.read_int_le::()?; + let sourcemap_byte_length = reader.read_int_le::()?; + let sourcemap_hash = reader.read_int_le::()?; - self.sourcemap_byte_offset = reader.read_int_le::()?; - self.sourcemap_byte_length = reader.read_int_le::()?; - self.sourcemap_hash = reader.read_int_le::()?; + let esm_record_byte_offset = reader.read_int_le::()?; + let esm_record_byte_length = reader.read_int_le::()?; + let esm_record_hash = reader.read_int_le::()?; + debug_assert!(reader.pos == Self::FIELDS_SIZE); - self.esm_record_byte_offset = reader.read_int_le::()?; - self.esm_record_byte_length = reader.read_int_le::()?; - self.esm_record_hash = reader.read_int_le::()?; + let fields_hash = reader.read_int_le::()?; + verify_hash(&bytes[..Self::FIELDS_SIZE], fields_hash)?; - self.module_type = match module_type_raw { + let module_type = match module_type_raw { 1 => ModuleType::Esm, 2 => ModuleType::Cjs, - // Invalid module type _ => return Err(crate::CrateError::InvalidModuleType), }; - self.output_encoding = Encoding(output_encoding_raw); - match self.output_encoding { + let output_encoding = Encoding(output_encoding_raw); + match output_encoding { Encoding::UTF8 | Encoding::UTF16 | Encoding::LATIN1 => {} - // Invalid encoding _ => return Err(crate::CrateError::UnknownEncoding), } + Ok(Metadata { + cache_version, + output_encoding, + module_type, + features_hash, + input_byte_length, + input_hash, + output_byte_offset, + output_byte_length, + output_hash, + sourcemap_byte_offset, + sourcemap_byte_length, + sourcemap_hash, + esm_record_byte_offset, + esm_record_byte_length, + esm_record_hash, + }) + } + + /// `save` writes the three sections back to back right after the header, + /// so the header describes the file size exactly. Checking that here means + /// `Entry::load` never allocates for, or reads at, a length or offset the + /// file does not have. + pub(crate) fn verify_layout(&self, file_size: u64) -> crate::CrateResult<()> { + let header_end = Self::SIZE as u64; + let output_end = header_end.checked_add(self.output_byte_length); + let sourcemap_end = output_end.and_then(|end| end.checked_add(self.sourcemap_byte_length)); + let esm_record_end = + sourcemap_end.and_then(|end| end.checked_add(self.esm_record_byte_length)); + + let consistent = self.output_byte_offset == header_end + && output_end == Some(self.sourcemap_byte_offset) + && sourcemap_end == Some(self.esm_record_byte_offset) + && esm_record_end == Some(file_size) + && (self.output_encoding != Encoding::UTF16 + || self.output_byte_length.is_multiple_of(2)); + if !consistent { + return Err(crate::CrateError::InvalidLayout); + } Ok(()) } } -// Static assert that `encode()` writes exactly `Metadata::SIZE` bytes — guards -// against the hand-summed constant drifting from the field list. -const _: () = assert!(Metadata::SIZE == 4 + 1 + 1 + 12 * 8); - pub enum OutputCode { Utf8(Box<[u8]>), String(BunString), @@ -302,9 +346,9 @@ impl Entry { } }); - let mut metadata_buf = [0u8; Metadata::SIZE * 2]; - let metadata_bytes_len: usize = { - let mut metadata = Metadata { + let mut metadata_buf = [0u8; Metadata::SIZE]; + { + let metadata = Metadata { input_byte_length, input_hash, features_hash, @@ -333,36 +377,26 @@ impl Entry { esm_record_byte_offset: (Metadata::SIZE + output_bytes.len() + sourcemap.len()) as u64, esm_record_byte_length: esm_record.len() as u64, + output_hash: hash(output_bytes), + sourcemap_hash: hash(sourcemap), + esm_record_hash: hash(esm_record), ..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); - } - - let mut metadata_stream = bun_io::FixedBufferStream::new_mut(&mut metadata_buf[..]); - metadata.encode(&mut metadata_stream)?; - let pos = metadata_stream.pos; + metadata.encode(&mut metadata_buf)?; #[cfg(debug_assertions)] { - let mut reader = - bun_io::FixedBufferStream::new(&metadata_buf[0..Metadata::SIZE]); - let mut metadata2 = Metadata::default(); - if let Err(err) = metadata2.decode(&mut reader) { - bun_core::Output::panic(format_args!( + match Metadata::decode(&metadata_buf) { + Ok(metadata2) => debug_assert!(metadata == metadata2), + Err(err) => bun_core::Output::panic(format_args!( "Metadata did not roundtrip encode -> decode successfully: {}", err.name(), - )); + )), } - debug_assert!(metadata == metadata2); } - - pos - }; - let metadata_bytes: &[u8] = &metadata_buf[0..metadata_bytes_len]; + } + let metadata_bytes: &[u8] = &metadata_buf[..]; let mut vecs_buf: [sys::PlatformIoVecConst; 4] = bun_core::ffi::zeroed(); let mut vecs_i: usize = 0; @@ -422,22 +456,17 @@ impl Entry { Ok(()) } + /// Reads the three sections. The caller has run `Metadata::verify_layout` + /// against the file size, so every length below fits the file and a short + /// read means the file changed underneath us. pub(crate) fn load(&mut self, file: &sys::File) -> crate::CrateResult<()> { - let stat_size = file.get_end_pos()? as u64; - if stat_size - < (Metadata::SIZE as u64) - + self.metadata.output_byte_length - + self.metadata.sourcemap_byte_length - { - return Err(crate::CrateError::MissingData); - } - debug_assert!( matches!(&self.output_code, OutputCode::Utf8(b) if b.is_empty()), "this should be the default value" ); self.output_code = if self.metadata.output_byte_length == 0 { + verify_hash(&[], self.metadata.output_hash)?; OutputCode::String(BunString::empty()) } else { match self.metadata.output_encoding { @@ -470,13 +499,10 @@ impl Entry { // errdefer scratch.deref() — BunString is `Copy`, so guard explicitly. let errdefer = scopeguard::guard(scratch, |s| s.deref()); let read_bytes = file.pread_all(bytes, self.metadata.output_byte_offset)?; - if read_bytes as u64 != self.metadata.output_byte_length { + if read_bytes != len { return Err(crate::CrateError::MissingData); } - - if self.metadata.output_hash != 0 && hash(bytes) != self.metadata.output_hash { - return Err(crate::CrateError::InvalidHash); - } + verify_hash(bytes, self.metadata.output_hash)?; if bun_core::strings::is_all_ascii(bytes) { // Fast path: ASCII ⊂ Latin-1, so `scratch` is already @@ -503,16 +529,10 @@ 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 { + if read_bytes != len { return Err(crate::CrateError::MissingData); } + verify_hash(bytes, self.metadata.output_hash)?; scopeguard::ScopeGuard::into_inner(errdefer); OutputCode::String(latin1) @@ -536,13 +556,7 @@ impl Entry { if read_bytes as u64 != self.metadata.output_byte_length { 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); - } - } + verify_hash(chars_bytes, self.metadata.output_hash)?; scopeguard::ScopeGuard::into_inner(errdefer); OutputCode::String(string) @@ -556,29 +570,21 @@ impl Entry { // deref the WTFStringImpl — must do it explicitly here. let output_code_errdefer = scopeguard::guard(&mut self.output_code, |oc| oc.deinit()); - if self.metadata.sourcemap_byte_length > 0 { - self.sourcemap = pread_box( - file, - self.metadata.sourcemap_byte_length as usize, - self.metadata.sourcemap_byte_offset, - )?; - } - - if self.metadata.esm_record_byte_length > 0 { - let esm_record = pread_box( - file, - self.metadata.esm_record_byte_length as usize, - 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); - } - } + let sourcemap = pread_box( + file, + self.metadata.sourcemap_byte_length as usize, + self.metadata.sourcemap_byte_offset, + )?; + verify_hash(&sourcemap, self.metadata.sourcemap_hash)?; + self.sourcemap = sourcemap; - self.esm_record = esm_record; - } + let esm_record = pread_box( + file, + self.metadata.esm_record_byte_length as usize, + self.metadata.esm_record_byte_offset, + )?; + verify_hash(&esm_record, self.metadata.esm_record_hash)?; + self.esm_record = esm_record; scopeguard::ScopeGuard::into_inner(output_code_errdefer); Ok(()) @@ -613,6 +619,13 @@ pub(crate) fn hash(bytes: &[u8]) -> u64 { Wyhash::hash(SEED, bytes) } +fn verify_hash(bytes: &[u8], expected: u64) -> crate::CrateResult<()> { + if hash(bytes) != expected { + return Err(crate::CrateError::InvalidHash); + } + Ok(()) +} + /// Allocate `len` bytes and fill them via `pread_all` at `offset`, returning /// `MissingData` on a short read. /// @@ -801,39 +814,51 @@ impl RuntimeTranspilerCache { feature_hash: u64, input_stat_size: u64, ) -> crate::CrateResult { - let mut metadata_bytes_buf = [0u8; Metadata::SIZE * 2]; - let cache_fd = sys::open(cache_file_path, sys::O::RDONLY, 0)?; + let mut metadata_bytes_buf = [0u8; Metadata::SIZE]; + // NONBLOCK so that a FIFO left at this path cannot block the open + // (the fstat below then rejects it). Not on Windows, where the flag + // would open an overlapped handle and break the synchronous preads. + #[cfg(unix)] + let open_flags = sys::O::RDONLY | sys::O::NONBLOCK; + #[cfg(not(unix))] + let open_flags = sys::O::RDONLY; + let cache_fd = sys::open(cache_file_path, open_flags, 0)?; let file = sys::File::from_fd(cache_fd); // On any error, delete the cache file. let unlink_guard = scopeguard::guard(cache_file_path, |p| { let _ = sys::unlink(p); }); + + let stat = file.stat()?; + if !sys::S::ISREG(stat.st_mode as _) { + return Err(crate::CrateError::NotARegularFile); + } + let file_size = + usize::try_from(stat.st_size).map_err(|_| crate::CrateError::InvalidLayout)?; + let metadata_bytes = file.pread_all(&mut metadata_bytes_buf, 0)?; #[cfg(windows)] { file.seek_to(0)?; } - let mut reader = bun_io::FixedBufferStream::new(&metadata_bytes_buf[0..metadata_bytes]); - let mut entry = Entry { - metadata: Metadata::default(), - output_code: OutputCode::Utf8(Box::default()), - sourcemap: Box::default(), - esm_record: Box::default(), - }; - entry.metadata.decode(&mut reader)?; - if entry.metadata.input_hash != input_hash - || entry.metadata.input_byte_length != input_stat_size - { + let metadata = Metadata::decode(&metadata_bytes_buf[..metadata_bytes])?; + if metadata.input_hash != input_hash || metadata.input_byte_length != input_stat_size { // delete the cache in this case return Err(crate::CrateError::InvalidInputHash); } - if entry.metadata.features_hash != feature_hash { + if metadata.features_hash != feature_hash { // delete the cache in this case return Err(crate::CrateError::MismatchedFeatureHash); } + metadata.verify_layout(file_size as u64)?; + + let mut entry = Entry { + metadata, + ..Default::default() + }; entry.load(&file)?; let _ = scopeguard::ScopeGuard::into_inner(unlink_guard); diff --git a/src/jsc/error.rs b/src/jsc/error.rs index b380bc476311..a35bc44a8197 100644 --- a/src/jsc/error.rs +++ b/src/jsc/error.rs @@ -20,6 +20,10 @@ pub enum Error { MissingData, #[error("InvalidHash")] InvalidHash, + #[error("InvalidLayout")] + InvalidLayout, + #[error("NotARegularFile")] + NotARegularFile, #[error("CacheDisabled")] CacheDisabled, #[error("InvalidInputHash")] @@ -100,6 +104,8 @@ impl Error { Self::WriteFailed => "WriteFailed", Self::MissingData => "MissingData", Self::InvalidHash => "InvalidHash", + Self::InvalidLayout => "InvalidLayout", + Self::NotARegularFile => "NotARegularFile", Self::CacheDisabled => "CacheDisabled", Self::InvalidInputHash => "InvalidInputHash", Self::MismatchedFeatureHash => "MismatchedFeatureHash", diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 01d569bb15ab..649a059133cf 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -1,7 +1,19 @@ 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 { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "fs"; +import { bunEnv, bunExe, bunRun, isWindows, tmpdirSync } from "harness"; +import { mkfifo } from "mkfifo"; import { join } from "path"; function dummyFile(size: number, cache_bust: string, value: string | { code: string }) { @@ -13,6 +25,52 @@ function dummyFile(size: number, cache_bust: string, value: string | { code: str return data; } +// Layout of a cache entry (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode): +// 0: cache_version u32, 4: module_type u8, 5: output_encoding u8, then +// twelve little-endian u64 fields: features_hash, input_byte_length, +// input_hash, and byte_offset / byte_length / hash for each of the output, +// sourcemap and esm_record sections. 102: wyhash of bytes 0..102. The three +// sections follow the header back to back, starting at 110. +const pile = { + MODULE_TYPE_AT: 4, + OUTPUT_ENCODING_AT: 5, + OUTPUT_BYTE_OFFSET_AT: 30, + OUTPUT_BYTE_LENGTH_AT: 38, + OUTPUT_HASH_AT: 46, + SOURCEMAP_BYTE_OFFSET_AT: 54, + SOURCEMAP_BYTE_LENGTH_AT: 62, + ESM_RECORD_BYTE_OFFSET_AT: 78, + ESM_RECORD_BYTE_LENGTH_AT: 86, + ESM_RECORD_HASH_AT: 94, + HEADER_HASH_AT: 102, + HEADER_SIZE: 110, + // `SEED` in RuntimeTranspilerCache.rs. `Bun.hash.wyhash` is the same function + // the cache uses for every hash in the entry. + SEED: 42n, +}; + +function pileHash(bytes: Uint8Array): bigint { + return Bun.hash.wyhash(bytes, pile.SEED); +} + +function pileSection(entry: Buffer, offsetAt: number, lengthAt: number): Buffer { + const offset = Number(entry.readBigUInt64LE(offsetAt)); + const length = Number(entry.readBigUInt64LE(lengthAt)); + return entry.subarray(offset, offset + length); +} + +/** Makes deliberate edits to the header fields pass the header hash check. */ +function signPileHeader(entry: Buffer): Buffer { + entry.writeBigUInt64LE(pileHash(entry.subarray(0, pile.HEADER_HASH_AT)), pile.HEADER_HASH_AT); + return entry; +} + +/** A rejected entry is unlinked and written again, which changes both of these. */ +function fileIdentity(path: string) { + const { ino, mtimeMs } = statSync(path); + return { ino, mtimeMs }; +} + let temp_dir: string = ""; let cache_dir = ""; @@ -70,8 +128,14 @@ describe("transpiler cache", () => { expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn(""); expect(existsSync(cache_dir)).toBeTrue(); expect(newCacheCount()).toBe(1); + const entry = join(cache_dir, readdirSync(cache_dir)[0]); + expect(readFileSync(entry).readBigUInt64LE(pile.OUTPUT_BYTE_LENGTH_AT)).toBe(0n); + const before = fileIdentity(entry); expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn(""); expect(newCacheCount()).toBe(0); + // The entry with the empty output section was used, not rejected and + // written again. + expect(fileIdentity(entry)).toEqual(before); }); test("ignores files under the minimum cache size", async () => { // MINIMUM_CACHE_SIZE is 4 KiB (src/jsc/RuntimeTranspilerCache.rs); files @@ -198,6 +262,120 @@ describe("transpiler cache", () => { chmodSync(join(cache_dir), "777"); } }); + + // An entry is bun's own earlier output. If it does not read back exactly as + // it was written (disk error, torn write, another writer), the module must + // still run from source and the entry must be written again. + describe("damaged entries", () => { + async function primeEntry(marker: string) { + writeFileSync(join(temp_dir, "a.js"), dummyFile(8 * 1024, "damaged", marker)); + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn(marker); + expect(newCacheCount()).toBe(1); + const entry = join(cache_dir, readdirSync(cache_dir)[0]); + return { entry, good: readFileSync(entry) }; + } + + test("are replaced and the module still runs", async () => { + const { entry, good } = await primeEntry("intact"); + const sourcemap = pileSection(good, pile.SOURCEMAP_BYTE_OFFSET_AT, pile.SOURCEMAP_BYTE_LENGTH_AT); + expect(sourcemap.length).toBeGreaterThan(0); + + const damage: Record Buffer> = { + // The header is taken at face value: the module ran as an empty file. + "output length zeroed": data => { + data.writeBigUInt64LE(0n, pile.OUTPUT_BYTE_LENGTH_AT); + return data; + }, + "sourcemap length zeroed": data => { + data.writeBigUInt64LE(0n, pile.SOURCEMAP_BYTE_LENGTH_AT); + return data; + }, + "module type flipped": data => { + data[pile.MODULE_TYPE_AT] = data[pile.MODULE_TYPE_AT] === 1 ? 2 : 1; + return data; + }, + "output encoding flipped": data => { + data[pile.OUTPUT_ENCODING_AT] = data[pile.OUTPUT_ENCODING_AT] === 1 ? 3 : 1; + return data; + }, + "header hash zeroed": data => { + data.writeBigUInt64LE(0n, pile.HEADER_HASH_AT); + return data; + }, + // A header that passes its own hash check still has to describe the + // file. Adding the lengths up used to overflow. + "output length u64::MAX in a signed header": data => { + data.writeBigUInt64LE(0xffff_ffff_ffff_ffffn, pile.OUTPUT_BYTE_LENGTH_AT); + return signPileHeader(data); + }, + "bytes appended": data => Buffer.concat([data, Buffer.alloc(16)]), + "last byte removed": data => data.subarray(0, data.length - 1), + // The sourcemap section was never checked against its hash. + "sourcemap byte flipped": data => { + const offset = Number(data.readBigUInt64LE(pile.SOURCEMAP_BYTE_OFFSET_AT)); + data[offset + (sourcemap.length >> 1)] ^= 0xff; + return data; + }, + }; + + const results: Record = {}; + const expected: Record = {}; + for (const [name, apply] of Object.entries(damage)) { + writeFileSync(entry, apply(Buffer.from(good))); + const { stdout, stderr, exitCode } = await bunRun(join(temp_dir, "a.js"), env); + results[name] = { + stdout, + stderr, + exitCode, + cacheFiles: newCacheCount(), + replaced: readFileSync(entry).equals(good), + }; + expected[name] = { stdout: "intact", stderr: "", exitCode: 0, cacheFiles: 0, replaced: true }; + } + expect(results).toEqual(expected); + }); + + test("an entry whose hashes match is used as written", async () => { + const { entry } = await primeEntry("ORIGINAL"); + const edited = readFileSync(entry); + const output = pileSection(edited, pile.OUTPUT_BYTE_OFFSET_AT, pile.OUTPUT_BYTE_LENGTH_AT); + const at = output.indexOf("ORIGINAL"); + expect(at).toBeGreaterThanOrEqual(0); + output.write("REPLACED", at); + edited.writeBigUInt64LE(pileHash(output), pile.OUTPUT_HASH_AT); + writeFileSync(entry, signPileHeader(edited)); + + // Proves the checks above are what rejects a damaged entry: an entry + // that is consistent with itself is served from the cache. + const before = fileIdentity(entry); + expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("REPLACED"); + expect(fileIdentity(entry)).toEqual(before); + }); + + test.skipIf(isWindows)("a fifo in place of an entry is removed instead of opened", async () => { + const { entry, good } = await primeEntry("intact"); + unlinkSync(entry); + mkfifo(entry); + + // Opening the fifo for reading used to block until a writer showed up, + // which never happens. The timeout only turns that hang into a failure. + await using proc = Bun.spawn({ + cmd: [bunExe(), join(temp_dir, "a.js")], + env, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("intact\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + expect(statSync(entry).isFile()).toBeTrue(); + expect(readFileSync(entry).equals(good)).toBeTrue(); + }); + }); test("does not inline process.env", async () => { writeFileSync( join(temp_dir, "a.js"), @@ -302,43 +480,32 @@ test("rejects cached module records containing out-of-range string indices", () // record, so any index beyond the table length (other than the reserved // *-default / *-namespace sentinels near u32::MAX) must be rejected. // - // Cache entry layout (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode): - // 0: cache_version u32, 4: module_type u8, 5: output_encoding u8, - // then twelve u64 fields; esm_record_byte_offset @ 78, - // esm_record_byte_length @ 86, esm_record_hash @ 94. Payload follows @ 102. // Serialized module record layout (src/bundler/analyze_transpiled_module.rs, // serialize()): // [record_kinds_len u32][record_kinds, 1 byte each][pad to 4] // [buffer_len u32][buffer: u32 string index x buffer_len] ... - const ESM_RECORD_BYTE_OFFSET_AT = 78; - const ESM_RECORD_BYTE_LENGTH_AT = 86; - const ESM_RECORD_HASH_AT = 94; - const METADATA_SIZE = 102; - function corruptModuleRecordStringIndices(file: string): boolean { const data = readFileSync(file); - if (data.length < METADATA_SIZE) return false; - const esmOff = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_OFFSET_AT)); - const esmLen = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_LENGTH_AT)); - if (esmLen === 0 || esmOff + esmLen > data.length) return false; + if (data.length < pile.HEADER_SIZE) return false; + const record = pileSection(data, pile.ESM_RECORD_BYTE_OFFSET_AT, pile.ESM_RECORD_BYTE_LENGTH_AT); + if (record.length === 0) return false; - const recordKindsLen = data.readUInt32LE(esmOff); + const recordKindsLen = record.readUInt32LE(0); const pad = (4 - (recordKindsLen % 4)) % 4; - let off = esmOff + 4 + recordKindsLen + pad; - const bufferLen = data.readUInt32LE(off); + let off = 4 + recordKindsLen + pad; + const bufferLen = record.readUInt32LE(off); off += 4; if (bufferLen === 0) return false; // Point every string index in the record buffer far beyond the identifier // table (but below the reserved sentinel range near u32::MAX). for (let i = 0; i < bufferLen; i++) { - data.writeUInt32LE(0x7fffffff, off + i * 4); + record.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); - writeFileSync(file, data); + // Keep the entry consistent with itself so the rewritten record gets past + // the cache loader and reaches the module record deserializer under test. + data.writeBigUInt64LE(pileHash(record), pile.ESM_RECORD_HASH_AT); + writeFileSync(file, signPileHeader(data)); return true; } From ec9d30e09e8d3180aa252bd8a80460900c3b0ee6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:35:03 +0000 Subject: [PATCH 2/4] test: compare nanosecond timestamps when checking that a cache entry was not rewritten --- test/cli/run/transpiler-cache.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 649a059133cf..62851d33e273 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -65,10 +65,15 @@ function signPileHeader(entry: Buffer): Buffer { return entry; } -/** A rejected entry is unlinked and written again, which changes both of these. */ +/** + * A rejected entry is unlinked and written again, which gives it new + * timestamps (and usually a new inode). A hit only reads it. + */ function fileIdentity(path: string) { - const { ino, mtimeMs } = statSync(path); - return { ino, mtimeMs }; + const { ino, mtimeNs, ctimeNs } = statSync(path, { bigint: true }); + expect(mtimeNs).toBeGreaterThan(0n); + expect(ctimeNs).toBeGreaterThan(0n); + return { ino, mtimeNs, ctimeNs }; } let temp_dir: string = ""; From e676b8373a6057093f6f04fd35a0013bb68771ee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:40:31 +0000 Subject: [PATCH 3/4] transpiler cache: shorten the new doc comments --- src/jsc/RuntimeTranspilerCache.rs | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index f853e37276ef..4a1bd0067e47 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -52,10 +52,8 @@ 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). -/// Version 26: The header ends with a hash of the header fields, and every -/// section hash is stored (and checked) even when the section is empty. A -/// damaged header used to be acted on as written: a zeroed output length ran -/// the module as an empty file. +/// Version 26: The header ends with a hash of the header fields, and the hash +/// of an empty section is stored and checked like any other. const EXPECTED_VERSION: u32 = 26; /// Source files smaller than this are not written to / read from the on-disk @@ -179,8 +177,6 @@ impl Metadata { Ok(()) } - /// Decodes the header at the start of `bytes`. No field is returned to - /// the caller before the version and the header hash have been checked. pub(crate) fn decode(bytes: &[u8]) -> crate::CrateResult { let mut reader = bun_io::FixedBufferStream::new(bytes); let cache_version = reader.read_int_le::()?; @@ -243,10 +239,8 @@ impl Metadata { }) } - /// `save` writes the three sections back to back right after the header, - /// so the header describes the file size exactly. Checking that here means - /// `Entry::load` never allocates for, or reads at, a length or offset the - /// file does not have. + /// `save` writes the sections back to back after the header, so a valid + /// header adds up to the file size exactly. pub(crate) fn verify_layout(&self, file_size: u64) -> crate::CrateResult<()> { let header_end = Self::SIZE as u64; let output_end = header_end.checked_add(self.output_byte_length); @@ -456,9 +450,8 @@ impl Entry { Ok(()) } - /// Reads the three sections. The caller has run `Metadata::verify_layout` - /// against the file size, so every length below fits the file and a short - /// read means the file changed underneath us. + /// The caller has run `Metadata::verify_layout`, so every length below + /// fits the file. pub(crate) fn load(&mut self, file: &sys::File) -> crate::CrateResult<()> { debug_assert!( matches!(&self.output_code, OutputCode::Utf8(b) if b.is_empty()), @@ -815,9 +808,8 @@ impl RuntimeTranspilerCache { input_stat_size: u64, ) -> crate::CrateResult { let mut metadata_bytes_buf = [0u8; Metadata::SIZE]; - // NONBLOCK so that a FIFO left at this path cannot block the open - // (the fstat below then rejects it). Not on Windows, where the flag - // would open an overlapped handle and break the synchronous preads. + // NONBLOCK: a FIFO at this path must not block the open. On Windows + // the flag would make the handle overlapped and break the preads. #[cfg(unix)] let open_flags = sys::O::RDONLY | sys::O::NONBLOCK; #[cfg(not(unix))] From 70f9f0b39b9b91de6a631b6ab547fc2d7b8deb35 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:44:03 +0000 Subject: [PATCH 4/4] transpiler cache: one line per doc comment --- src/jsc/RuntimeTranspilerCache.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 4a1bd0067e47..e1ab42287750 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -52,8 +52,7 @@ 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). -/// Version 26: The header ends with a hash of the header fields, and the hash -/// of an empty section is stored and checked like any other. +/// Version 26: Trailing header hash. Empty sections store and check a hash too. const EXPECTED_VERSION: u32 = 26; /// Source files smaller than this are not written to / read from the on-disk @@ -239,8 +238,7 @@ impl Metadata { }) } - /// `save` writes the sections back to back after the header, so a valid - /// header adds up to the file size exactly. + /// `save` writes the sections back to back, so a valid header adds up to the file size. pub(crate) fn verify_layout(&self, file_size: u64) -> crate::CrateResult<()> { let header_end = Self::SIZE as u64; let output_end = header_end.checked_add(self.output_byte_length); @@ -450,8 +448,7 @@ impl Entry { Ok(()) } - /// The caller has run `Metadata::verify_layout`, so every length below - /// fits the file. + /// `Metadata::verify_layout` has run, so every length below fits the file. pub(crate) fn load(&mut self, file: &sys::File) -> crate::CrateResult<()> { debug_assert!( matches!(&self.output_code, OutputCode::Utf8(b) if b.is_empty()), @@ -808,8 +805,7 @@ impl RuntimeTranspilerCache { input_stat_size: u64, ) -> crate::CrateResult { let mut metadata_bytes_buf = [0u8; Metadata::SIZE]; - // NONBLOCK: a FIFO at this path must not block the open. On Windows - // the flag would make the handle overlapped and break the preads. + // NONBLOCK: a FIFO must not block the open. On Windows it would make the handle overlapped. #[cfg(unix)] let open_flags = sys::O::RDONLY | sys::O::NONBLOCK; #[cfg(not(unix))]