diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 30345f1578a6..e1ab42287750 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,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). -const EXPECTED_VERSION: u32 = 25; +/// 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 /// transpiler cache. Originally 50 KiB, which excluded almost every file in a @@ -141,9 +143,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 +170,95 @@ 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 { + 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 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); + 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 +338,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 +369,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 +448,15 @@ impl Entry { Ok(()) } + /// `Metadata::verify_layout` has run, so every length below fits the file. 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 +489,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 +519,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 +546,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 +560,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 +609,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 +804,49 @@ 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: 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))] + 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..62851d33e273 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,57 @@ 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 gives it new + * timestamps (and usually a new inode). A hit only reads it. + */ +function fileIdentity(path: string) { + const { ino, mtimeNs, ctimeNs } = statSync(path, { bigint: true }); + expect(mtimeNs).toBeGreaterThan(0n); + expect(ctimeNs).toBeGreaterThan(0n); + return { ino, mtimeNs, ctimeNs }; +} + let temp_dir: string = ""; let cache_dir = ""; @@ -70,8 +133,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 +267,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 +485,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; }