From c524fedef14a450c3ec8c94c9638da2047f2dc09 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:15:49 +0000 Subject: [PATCH 1/9] Add a per-file ratchet for expect("int cast") sites and clear json5, image codecs and elf test/internal/source-lints/int-cast-expects.test.ts pins the number of `.expect("int cast")` sites per Rust file (1151 sites in 273 files after this commit) so new ones fail the source lints and removals have to lower the limits file. json5: reject documents longer than i32::MAX up front (DocumentTooLarge, like the JSON parser) and build token locations with usize2loc. image codecs: a decoder-reported c_int dimension converts through positive_dimension (DecodeFailed), kernel dimensions through kernel_dimension (TooManyPixels), and rotate takes the u16 degrees the pipeline already stores. elf: every offset read from a --compile-executable-path template goes through file_range, which bounds-checks it against the file and returns InvalidElfFile. The .bun sh_offset, e_phoff, .shstrtab offset and e_shstrndx were previously used unchecked, and several header sums could overflow; a corrupt template used to abort the build with a slice-index panic. --- src/exe_format/elf.rs | 394 ++++++++---------- src/parsers/json5.rs | 86 ++-- src/runtime/image/Image.rs | 4 +- src/runtime/image/codecs.rs | 84 ++-- test/bundler/bundler_compile.test.ts | 231 +++++++++- .../source-lints/int-cast-expect-limits.json | 275 ++++++++++++ .../source-lints/int-cast-expects.test.ts | 91 ++++ 7 files changed, 855 insertions(+), 310 deletions(-) create mode 100644 test/internal/source-lints/int-cast-expect-limits.json create mode 100644 test/internal/source-lints/int-cast-expects.test.ts diff --git a/src/exe_format/elf.rs b/src/exe_format/elf.rs index 9d231ec56e0a..d3221cfaa721 100644 --- a/src/exe_format/elf.rs +++ b/src/exe_format/elf.rs @@ -7,6 +7,7 @@ //! Must work on any host platform (macOS, Windows, Linux) for cross-compilation. use core::mem::size_of; +use core::ops::Range; #[cfg(any(target_os = "linux", target_os = "android"))] use core::sync::atomic::{AtomicU8, Ordering}; @@ -64,35 +65,28 @@ impl ElfFile { } let ehdr = read_ehdr(&self.data); - let phdr_size = size_of::(); - - // Bounds-check the program header table up-front; --compile-executable-path - // accepts arbitrary files, so a corrupt e_phoff/e_phnum must not panic. - let phdr_table_end = ehdr - .e_phoff - .saturating_add((ehdr.e_phnum as u64).saturating_mul(phdr_size as u64)); - if phdr_table_end > self.data.len() as u64 { + + // --compile-executable-path accepts arbitrary files, and this rewrite is + // best-effort: a header table or PT_INTERP that does not fit in the + // file leaves the interpreter alone instead of failing the build. + let Ok(phdrs) = phdr_table(&self.data, ehdr) else { return; - } + }; - for i in 0..ehdr.e_phnum as usize { - let phdr_offset = usize::try_from(ehdr.e_phoff).expect("int cast") + i * phdr_size; - let phdr: Elf64_Phdr = read_struct(&self.data[phdr_offset..][..phdr_size]); + for phdr_offset in phdrs.step_by(PHDR_SIZE) { + let phdr = read_phdr(&self.data, phdr_offset); if phdr.p_type != PT_INTERP { continue; } - let interp_offset = usize::try_from(phdr.p_offset).expect("int cast"); - let interp_filesz = usize::try_from(phdr.p_filesz).expect("int cast"); - if interp_offset + interp_filesz > self.data.len() { + let Ok(interp) = file_range(&self.data, phdr.p_offset, phdr.p_filesz) else { return; - } + }; // reshaped for borrowck — compute replacement under an // immutable borrow, then take a mutable borrow for the writes. let replacement: &'static [u8] = { - let interp_region = &self.data[interp_offset..][..interp_filesz]; - let current = slice_to_nul(interp_region); + let current = slice_to_nul(&self.data[interp.clone()]); if !current.starts_with(b"/nix/store/") && !current.starts_with(b"/gnu/store/") { return; @@ -116,7 +110,7 @@ impl ElfFile { // FHS path + NUL must fit in the existing segment (always true for // store paths: 32-char hash + pname + "/lib/" alone exceeds any FHS path). - if replacement.len() + 1 > interp_filesz { + if replacement.len() + 1 > interp.len() { return; } @@ -131,7 +125,7 @@ impl ElfFile { }; { - let interp_region = &mut self.data[interp_offset..][..interp_filesz]; + let interp_region = &mut self.data[interp]; interp_region[..replacement.len()].copy_from_slice(replacement); interp_region[replacement.len()..].fill(0); } @@ -150,42 +144,30 @@ impl ElfFile { /// the rewritten PT_INTERP so `readelf -S` shows accurate metadata. The kernel /// only consults PT_INTERP, so any failure here is silently ignored. fn update_interp_section_size(&mut self, ehdr: Elf64_Ehdr, new_size: u64) { - let shdr_size = size_of::(); - let shnum = ehdr.e_shnum; - if shnum == 0 || ehdr.e_shstrndx >= shnum { + if ehdr.e_shstrndx >= ehdr.e_shnum { return; } - - let shdr_table_end = - (ehdr.e_shoff).saturating_add((shnum as u64).saturating_mul(shdr_size as u64)); - if shdr_table_end > self.data.len() as u64 { + let Ok(shdrs) = shdr_table(&self.data, ehdr) else { return; - } - - let strtab_shdr = self.read_shdr(ehdr.e_shoff, ehdr.e_shstrndx); - let strtab_end = strtab_shdr.sh_offset.saturating_add(strtab_shdr.sh_size); - if strtab_end > self.data.len() as u64 { + }; + let strtab_shdr = read_shdr(&self.data, &shdrs, ehdr.e_shstrndx); + let Ok(strtab) = file_range(&self.data, strtab_shdr.sh_offset, strtab_shdr.sh_size) else { return; - } - // reshaped for borrowck — copy strtab bounds out so we can - // re-borrow self.data mutably below. - let strtab_off = usize::try_from(strtab_shdr.sh_offset).expect("int cast"); - let strtab_len = usize::try_from(strtab_shdr.sh_size).expect("int cast"); - - for i in 0..shnum as usize { - let shdr = self.read_shdr(ehdr.e_shoff, u16::try_from(i).expect("int cast")); - if shdr.sh_name as usize >= strtab_len { + }; + + for i in 0..ehdr.e_shnum { + let shdr = read_shdr(&self.data, &shdrs, i); + let name_offset = shdr.sh_name as usize; + if name_offset >= strtab.len() { continue; } - let strtab = &self.data[strtab_off..][..strtab_len]; - let name = slice_to_nul(&strtab[shdr.sh_name as usize..]); - if name != b".interp" { + if slice_to_nul(&self.data[strtab.clone()][name_offset..]) != b".interp" { continue; } // sh_size @ +32 in Elf64_Shdr - let shdr_offset = usize::try_from(ehdr.e_shoff).expect("int cast") + i * shdr_size; - write_u64_le(&mut self.data[shdr_offset + 32..][..8], new_size); + let entry = shdr_offset(&shdrs, i); + write_u64_le(&mut self.data[entry + 32..][..8], new_size); return; } } @@ -211,8 +193,6 @@ impl ElfFile { pub fn write_bun_section(&mut self, payload: &[u8]) -> Result<(), ElfError> { let ehdr = read_ehdr(&self.data); let bun_section = self.find_bun_section(ehdr)?; - let bun_section_offset = bun_section.file_offset; - let bun_section_vaddr = bun_section.vaddr; let page_size = Self::page_size(ehdr); let header_size: u64 = size_of::() as u64; @@ -224,35 +204,38 @@ impl ElfFile { // PT_LOAD holding the relocated PHDR + `.interp`, #31023). Growing an // existing PT_LOAD rather than adding a late one is required by // WSL1's kernel loader (#29963). - let phdr_size = size_of::(); - let mut rw_phdr_index: Option = None; - let mut rw_phdr: Elf64_Phdr = Elf64_Phdr::ZEROED; + let mut rw_phdr: Option<(usize, Elf64_Phdr)> = None; let mut max_vaddr_end: u64 = 0; - for i in 0..ehdr.e_phnum as usize { - let phdr_offset = usize::try_from(ehdr.e_phoff).expect("int cast") + i * phdr_size; - let phdr: Elf64_Phdr = read_struct(&self.data[phdr_offset..][..phdr_size]); + for phdr_offset in phdr_table(&self.data, ehdr)?.step_by(PHDR_SIZE) { + let phdr = read_phdr(&self.data, phdr_offset); if phdr.p_type != PT_LOAD { continue; } - let vaddr_end = phdr.p_vaddr + phdr.p_memsz; - if vaddr_end > max_vaddr_end { - max_vaddr_end = vaddr_end; - } + let vaddr_end = phdr + .p_vaddr + .checked_add(phdr.p_memsz) + .ok_or(ElfError::InvalidElfFile)?; + max_vaddr_end = max_vaddr_end.max(vaddr_end); if (phdr.p_flags & PF_W) != 0 - && phdr.p_vaddr <= bun_section_vaddr - && bun_section_vaddr < vaddr_end + && phdr.p_vaddr <= bun_section.vaddr + && bun_section.vaddr < vaddr_end { - rw_phdr_index = Some(i); - rw_phdr = phdr; + rw_phdr = Some((phdr_offset, phdr)); } } - let Some(rw_index) = rw_phdr_index else { + let Some((rw_phdr_offset, rw_phdr)) = rw_phdr else { return Err(ElfError::NoWritableLoadSegment); }; + // Every file offset taken from the template's headers is checked + // against the file before the layout below is computed from it. + let old_rw_file_end = file_range(&self.data, rw_phdr.p_offset, rw_phdr.p_filesz)?.end; + let bun_vaddr_slot = file_range(&self.data, bun_section.file_offset, header_size)?; + let old_shdrs = shdr_table(&self.data, ehdr)?; + // Place the new data at a page-aligned virtual address past every // existing mapping. page_size is ≥ 128 so this also guarantees the // 128-byte alignment that JSC's bytecode cache requires — see @@ -266,9 +249,9 @@ impl ElfFile { // `new_file_offset` follows the segment's existing (vaddr - offset) // delta, so the kernel's mmap at `rw_phdr.p_offset → rw_phdr.p_vaddr` // covers our new payload continuously once we grow p_filesz. - let new_vaddr = align_up(max_vaddr_end, page_size); - let offset_in_segment = new_vaddr - rw_phdr.p_vaddr; - let new_file_offset = rw_phdr.p_offset + offset_in_segment; + let new_vaddr = max_vaddr_end + .checked_next_multiple_of(page_size) + .ok_or(ElfError::InvalidElfFile)?; // Sanity: `max_vaddr_end` already reflects the RW segment's full // memsz range (the loop above folds every PT_LOAD), so new_vaddr is @@ -277,6 +260,16 @@ impl ElfFile { if new_vaddr < rw_phdr.p_vaddr + rw_phdr.p_memsz { return Err(ElfError::NewVaddrCollides); } + let offset_in_segment = new_vaddr - rw_phdr.p_vaddr; + let new_file_offset = rw_phdr + .p_offset + .checked_add(offset_in_segment) + .ok_or(ElfError::InvalidElfFile)?; + let move_dst_start = new_file_offset + .checked_add(aligned_new_size) + .ok_or(ElfError::InvalidElfFile)?; + let new_file_offset = to_usize(new_file_offset)?; + let move_dst_start = to_usize(move_dst_start)?; // File layout after this function returns: // @@ -288,7 +281,7 @@ impl ElfFile { // zero to keep BSS semantics) // [new_file_offset, +aligned_new_size) [u64 LE size][payload][zero pad] // (new .bun contents — vaddr = new_vaddr) - // [payload_end, +moved_tail_size) relocated non-ALLOC sections + old + // [move_dst_start, +moved_tail_size) relocated non-ALLOC sections + old // section header table // // Anything past `old_rw_file_end` in the input — non-ALLOC sections @@ -297,72 +290,50 @@ impl ElfFile { // because that file range now lives inside the extended RW PT_LOAD. // Leaving it in place would mmap it into what was previously BSS // (zero-initialized statics), corrupting the process. - let old_rw_file_end = rw_phdr.p_offset + rw_phdr.p_filesz; - let old_file_size: u64 = self.data.len() as u64; - if old_rw_file_end > old_file_size { + // + // A segment whose file image is larger than its memory image + // (p_filesz > p_memsz) is malformed and would put `new_file_offset` + // inside the segment's existing bytes. The section header table must + // be part of the relocated tail: the payload is written over its old + // location. + let move_src_start = old_rw_file_end; + let move_src_end = self.data.len(); + if new_file_offset < move_src_start || old_shdrs.start < move_src_start { return Err(ElfError::InvalidElfFile); } - - let move_src_start: u64 = old_rw_file_end; - let move_src_end: u64 = old_file_size; - let moved_tail_size: u64 = move_src_end - move_src_start; - let move_dst_start: u64 = new_file_offset + aligned_new_size; - let move_dst_end: u64 = move_dst_start + moved_tail_size; - - let total_new_size: u64 = move_dst_end; + let moved_tail_size = move_src_end - move_src_start; + let total_new_size = move_dst_start + .checked_add(moved_tail_size) + .ok_or(ElfError::InvalidElfFile)?; // resize() zero-fills, so the explicit zero-fills below are // partially redundant but harmless. - let total_new_size_usz = usize::try_from(total_new_size).expect("int cast"); - self.data - .reserve(total_new_size_usz.saturating_sub(self.data.len())); - self.data.resize(total_new_size_usz, 0); + self.data.resize(total_new_size, 0); // Relocate the tail (non-ALLOC sections + old shdr table) past the // payload. Do this BEFORE zero-filling and writing the payload — if // `new_file_offset < old_file_size` (debug binaries with hundreds of // MB of debug info past the RW segment), the destination overlaps // the source, so memmove is required. - if moved_tail_size != 0 { - self.data.copy_within( - usize::try_from(move_src_start).expect("int cast") - ..usize::try_from(move_src_end).expect("int cast"), - usize::try_from(move_dst_start).expect("int cast"), - ); - } + self.data + .copy_within(move_src_start..move_src_end, move_dst_start); // Zero the bytes between the old RW file-content end and the payload // start. This entire range is now inside the extended PT_LOAD's // file-backed region; keeping it zero preserves BSS semantics. - self.data[usize::try_from(move_src_start).expect("int cast") - ..usize::try_from(new_file_offset).expect("int cast")] - .fill(0); + self.data[move_src_start..new_file_offset].fill(0); // Write the payload at the new location: [u64 LE size][data][zero padding] - write_u64_le( - &mut self.data[usize::try_from(new_file_offset).expect("int cast")..][..8], - payload.len() as u64, - ); - self.data[usize::try_from(new_file_offset + header_size).expect("int cast")..] - [..payload.len()] - .copy_from_slice(payload); - - // Zero the padding between payload end and the relocated tail - let payload_end = new_file_offset + new_content_size; - if move_dst_start > payload_end { - self.data[usize::try_from(payload_end).expect("int cast") - ..usize::try_from(move_dst_start).expect("int cast")] - .fill(0); - } + write_u64_le(&mut self.data[new_file_offset..][..8], payload.len() as u64); + let payload_start = new_file_offset + size_of::(); + self.data[payload_start..][..payload.len()].copy_from_slice(payload); + self.data[payload_start + payload.len()..move_dst_start].fill(0); // Write the vaddr of the appended data at the ORIGINAL .bun section location // (where BUN_COMPILED symbol points). At runtime, BUN_COMPILED.size will be // this vaddr (always non-zero), which the runtime dereferences as a pointer. // Non-standalone binaries have BUN_COMPILED.size = 0, so 0 means "no data". - write_u64_le( - &mut self.data[usize::try_from(bun_section_offset).expect("int cast")..][..8], - new_vaddr, - ); + write_u64_le(&mut self.data[bun_vaddr_slot], new_vaddr); // Update every section header whose sh_offset pointed into the moved // tail so tools like `readelf -S`, `objdump`, and `gdb` still find @@ -371,36 +342,26 @@ impl ElfFile { // // The section header table itself is part of the moved tail, so we // compute its new location from e_shoff's old value. - let old_shdr_offset: u64 = ehdr.e_shoff; - let shdr_table_size = ehdr.e_shnum as u64 * size_of::() as u64; - if old_shdr_offset < move_src_start || old_shdr_offset + shdr_table_size > move_src_end { - return Err(ElfError::InvalidElfFile); - } - let new_shdr_offset: u64 = old_shdr_offset + (move_dst_start - move_src_start); - self.write_ehdr_shoff(new_shdr_offset); - - let shnum = ehdr.e_shnum; - for i in 0..shnum as usize { - let shdr_file_offset: u64 = new_shdr_offset + i as u64 * size_of::() as u64; - let shdr_file_offset_usz = usize::try_from(shdr_file_offset).expect("int cast"); - let mut shdr: Elf64_Shdr = - read_struct(&self.data[shdr_file_offset_usz..][..size_of::()]); - - if i == bun_section.section_index as usize { - shdr.sh_offset = new_file_offset; + let tail_shift = move_dst_start - move_src_start; + let new_shdrs = old_shdrs.start + tail_shift..old_shdrs.end + tail_shift; + self.write_ehdr_shoff(new_shdrs.start as u64); + + for i in 0..ehdr.e_shnum { + let mut shdr = read_shdr(&self.data, &new_shdrs, i); + + if i == bun_section.section_index { + shdr.sh_offset = new_file_offset as u64; shdr.sh_size = new_content_size; shdr.sh_addr = new_vaddr; } else if shdr.sh_type != SHT_NOBITS - && shdr.sh_offset >= move_src_start - && shdr.sh_offset < move_src_end + && shdr.sh_offset >= move_src_start as u64 + && shdr.sh_offset < move_src_end as u64 { - shdr.sh_offset += move_dst_start - move_src_start; + shdr.sh_offset += tail_shift as u64; } - write_struct( - &mut self.data[shdr_file_offset_usz..][..size_of::()], - &shdr, - ); + let entry = shdr_offset(&new_shdrs, i); + write_struct(&mut self.data[entry..][..SHDR_SIZE], &shdr); } // Extend the existing writable PT_LOAD to cover the appended payload. @@ -410,22 +371,13 @@ impl ElfFile { // // PT_GNU_STACK is deliberately left alone; repurposing it into a // separate late PT_LOAD is what breaks WSL1 (#29963). - { - let new_segment_size = offset_in_segment + aligned_new_size; - let extended = Elf64_Phdr { - p_type: rw_phdr.p_type, - p_flags: rw_phdr.p_flags, - p_offset: rw_phdr.p_offset, - p_vaddr: rw_phdr.p_vaddr, - p_paddr: rw_phdr.p_paddr, - p_filesz: new_segment_size, - p_memsz: new_segment_size, - p_align: rw_phdr.p_align, - }; - let phdr_offset = - usize::try_from(ehdr.e_phoff).expect("int cast") + rw_index * phdr_size; - write_struct(&mut self.data[phdr_offset..][..phdr_size], &extended); - } + let new_segment_size = offset_in_segment + aligned_new_size; + let extended = Elf64_Phdr { + p_filesz: new_segment_size, + p_memsz: new_segment_size, + ..rw_phdr + }; + write_struct(&mut self.data[rw_phdr_offset..][..PHDR_SIZE], &extended); Ok(()) } @@ -434,55 +386,36 @@ impl ElfFile { /// Returns the file offset and section index of the `.bun` section. fn find_bun_section(&self, ehdr: Elf64_Ehdr) -> Result { - let shdr_size = size_of::(); - let shdr_table_offset = ehdr.e_shoff; - let shnum = ehdr.e_shnum; - - if shnum == 0 { + if ehdr.e_shnum == 0 { return Err(ElfError::BunSectionNotFound); } - if shdr_table_offset + shnum as u64 * shdr_size as u64 > self.data.len() as u64 { + if ehdr.e_shstrndx >= ehdr.e_shnum { return Err(ElfError::InvalidElfFile); } + let shdrs = shdr_table(&self.data, ehdr)?; // Read the .shstrtab section to get section names - let shstrtab_shdr = self.read_shdr(shdr_table_offset, ehdr.e_shstrndx); - let strtab_offset = shstrtab_shdr.sh_offset; - let strtab_size = shstrtab_shdr.sh_size; - - if strtab_offset + strtab_size > self.data.len() as u64 { - return Err(ElfError::InvalidElfFile); - } - let strtab = &self.data[usize::try_from(strtab_offset).expect("int cast")..] - [..usize::try_from(strtab_size).expect("int cast")]; + let shstrtab_shdr = read_shdr(&self.data, &shdrs, ehdr.e_shstrndx); + let strtab = + &self.data[file_range(&self.data, shstrtab_shdr.sh_offset, shstrtab_shdr.sh_size)?]; // Search for .bun section - for i in 0..shnum as usize { - let shdr = self.read_shdr(shdr_table_offset, u16::try_from(i).expect("int cast")); - let name_offset = shdr.sh_name; - - if (name_offset as usize) < strtab.len() { - let name = slice_to_nul(&strtab[name_offset as usize..]); - if name == b".bun" { - return Ok(BunSectionInfo { - file_offset: shdr.sh_offset, - vaddr: shdr.sh_addr, - section_index: u16::try_from(i).expect("int cast"), - }); - } + for i in 0..ehdr.e_shnum { + let shdr = read_shdr(&self.data, &shdrs, i); + let name_offset = shdr.sh_name as usize; + + if name_offset < strtab.len() && slice_to_nul(&strtab[name_offset..]) == b".bun" { + return Ok(BunSectionInfo { + file_offset: shdr.sh_offset, + vaddr: shdr.sh_addr, + section_index: i, + }); } } Err(ElfError::BunSectionNotFound) } - fn read_shdr(&self, table_offset: u64, index: u16) -> Elf64_Shdr { - let offset = table_offset + index as u64 * size_of::() as u64; - read_struct( - &self.data[usize::try_from(offset).expect("int cast")..][..size_of::()], - ) - } - fn write_ehdr_shoff(&mut self, new_shoff: u64) { // e_shoff is at offset 40 in Elf64_Ehdr write_u64_le(&mut self.data[40..][..8], new_shoff); @@ -496,6 +429,61 @@ impl ElfFile { } } +const PHDR_SIZE: usize = size_of::(); +const SHDR_SIZE: usize = size_of::(); + +/// The file range `offset..offset + len` described by a pair of header fields, +/// or `InvalidElfFile` if it overflows or runs past the end of `data`. Every +/// offset read from a template goes through here before it is used to slice: +/// `--compile-executable-path` accepts arbitrary files. +fn file_range(data: &[u8], offset: u64, len: u64) -> Result, ElfError> { + let start = to_usize(offset)?; + let end = start + .checked_add(to_usize(len)?) + .ok_or(ElfError::InvalidElfFile)?; + if end > data.len() { + return Err(ElfError::InvalidElfFile); + } + Ok(start..end) +} + +/// An ELF64 offset or size as a slice index. Only fails on a target whose +/// `usize` is narrower than the 64-bit fields. +fn to_usize(value: u64) -> Result { + usize::try_from(value).map_err(|_| ElfError::InvalidElfFile) +} + +fn phdr_table(data: &[u8], ehdr: Elf64_Ehdr) -> Result, ElfError> { + file_range( + data, + ehdr.e_phoff, + u64::from(ehdr.e_phnum) * PHDR_SIZE as u64, + ) +} + +fn shdr_table(data: &[u8], ehdr: Elf64_Ehdr) -> Result, ElfError> { + file_range( + data, + ehdr.e_shoff, + u64::from(ehdr.e_shnum) * SHDR_SIZE as u64, + ) +} + +/// `offset` is an entry of a table returned by [`phdr_table`]. +fn read_phdr(data: &[u8], offset: usize) -> Elf64_Phdr { + read_struct(&data[offset..][..PHDR_SIZE]) +} + +/// `table` comes from [`shdr_table`] and `index` is below the `e_shnum` it was +/// computed from. +fn shdr_offset(table: &Range, index: u16) -> usize { + table.start + usize::from(index) * SHDR_SIZE +} + +fn read_shdr(data: &[u8], table: &Range, index: u16) -> Elf64_Shdr { + read_struct(&data[shdr_offset(table, index)..][..SHDR_SIZE]) +} + struct BunSectionInfo { /// File offset of the .bun section's data (sh_offset). file_offset: u64, @@ -619,27 +607,20 @@ fn host_uses_nix_store_interpreter() -> bool { } let ehdr = read_ehdr(data); - let phdr_size = size_of::(); - let table_end = (ehdr.e_phoff) - .saturating_add((ehdr.e_phnum as u64).saturating_mul(phdr_size as u64)); - if table_end > data.len() as u64 { + let Ok(phdrs) = phdr_table(data, ehdr) else { return false; - } + }; - for i in 0..ehdr.e_phnum as usize { - let off = usize::try_from(ehdr.e_phoff).expect("int cast") + i * phdr_size; - let phdr: Elf64_Phdr = read_struct(&data[off..][..phdr_size]); + for phdr_offset in phdrs.step_by(PHDR_SIZE) { + let phdr = read_phdr(data, phdr_offset); if phdr.p_type != PT_INTERP { continue; } - let interp_off = usize::try_from(phdr.p_offset).expect("int cast"); - let interp_sz = usize::try_from(phdr.p_filesz).expect("int cast"); - if interp_off + interp_sz > data.len() { + let Ok(interp) = file_range(data, phdr.p_offset, phdr.p_filesz) else { return false; - } - - let interp = slice_to_nul(&data[interp_off..][..interp_sz]); + }; + let interp = slice_to_nul(&data[interp]); return interp.starts_with(b"/nix/store/") || interp.starts_with(b"/gnu/store/"); } false @@ -704,19 +685,6 @@ pub(crate) struct Elf64_Phdr { pub p_align: u64, } -impl Elf64_Phdr { - const ZEROED: Self = Self { - p_type: 0, - p_flags: 0, - p_offset: 0, - p_vaddr: 0, - p_paddr: 0, - p_filesz: 0, - p_memsz: 0, - p_align: 0, - }; -} - #[repr(C)] #[derive(Clone, Copy)] #[allow(non_camel_case_types, non_snake_case)] diff --git a/src/parsers/json5.rs b/src/parsers/json5.rs index af47b87f7258..92c663393a24 100644 --- a/src/parsers/json5.rs +++ b/src/parsers/json5.rs @@ -15,7 +15,7 @@ use bun_core::StackCheck; // `is_identifier_start/_part` landed in `bun_core::lexer`; route through there. use bun_alloc::{ArenaVec as BumpVec, ArenaVecExt as _}; use bun_ast::{E, Expr, G}; -use bun_ast::{Loc, Log, Source}; +use bun_ast::{Loc, Log, Source, usize2loc}; use bun_core::lexer as identifier; use bun_core::strings; @@ -92,6 +92,7 @@ pub enum ParseError { ExpectedClosingBracket, InvalidIdentifier, TrailingData, + DocumentTooLarge, StackOverflow, } @@ -123,6 +124,7 @@ pub enum Error { ExpectedClosingBracket { pos: usize }, InvalidIdentifier { pos: usize }, TrailingData { pos: usize }, + DocumentTooLarge, } #[derive(Copy, Clone, PartialEq, Eq, strum::IntoStaticStr, Debug)] @@ -165,9 +167,8 @@ impl Error { | Error::ExpectedClosingBrace { pos } | Error::ExpectedClosingBracket { pos } | Error::InvalidIdentifier { pos } - | Error::TrailingData { pos } => Loc { - start: i32::try_from(pos).expect("int cast"), - }, + | Error::TrailingData { pos } => usize2loc(pos), + Error::DocumentTooLarge => Loc { start: 0 }, }; let msg: &'static [u8] = match *self { Error::Oom | Error::StackOverflow => unreachable!(), @@ -191,6 +192,7 @@ impl Error { Error::ExpectedClosingBracket { .. } => b"Expected ']'", Error::InvalidIdentifier { .. } => b"Invalid identifier start character", Error::TrailingData { .. } => b"Unexpected token after JSON5 value", + Error::DocumentTooLarge => b"JSON5 document is too large to parse (2 GiB maximum)", }; log.add_error(Some(source), loc, msg); Ok(()) @@ -222,6 +224,7 @@ impl<'a> JSON5Parser<'a> { match err { ParseError::OutOfMemory => Error::Oom, ParseError::StackOverflow => Error::StackOverflow, + ParseError::DocumentTooLarge => Error::DocumentTooLarge, // Scanner errors use scan position ParseError::UnexpectedCharacter => Error::UnexpectedCharacter { pos: scan_pos }, ParseError::UnterminatedString => Error::UnterminatedString { pos: scan_pos }, @@ -288,13 +291,17 @@ impl<'a> JSON5Parser<'a> { 0 } + /// Records the current position as the start of the token being scanned. + /// `parse_root` rejects documents whose positions do not fit a `Loc`. + fn start_token(&mut self) { + self.token.loc = usize2loc(self.pos); + } + fn scan(&mut self) -> Result<(), ParseError> { self.token.data = 'next: loop { match self.peek() { 0 => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); break 'next TokenData::Eof; } // Whitespace — skip without setting loc @@ -304,73 +311,53 @@ impl<'a> JSON5Parser<'a> { } // Structural b'{' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); self.pos += 1; break 'next TokenData::LeftBrace; } b'}' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); self.pos += 1; break 'next TokenData::RightBrace; } b'[' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); self.pos += 1; break 'next TokenData::LeftBracket; } b']' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); self.pos += 1; break 'next TokenData::RightBracket; } b':' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); self.pos += 1; break 'next TokenData::Colon; } b',' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); self.pos += 1; break 'next TokenData::Comma; } b'+' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); self.pos += 1; break 'next TokenData::Number(self.scan_signed_value(false)?); } b'-' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); self.pos += 1; break 'next TokenData::Number(self.scan_signed_value(true)?); } // Strings b'"' | b'\'' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); break 'next TokenData::String(self.scan_string()?); } // Numbers b'0'..=b'9' | b'.' => { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); break 'next TokenData::Number(self.scan_number()?); } // Comments — skip without setting loc @@ -393,27 +380,21 @@ impl<'a> JSON5Parser<'a> { } c => { if c == b't' { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); break 'next if self.scan_keyword(b"true") { TokenData::Boolean(true) } else { TokenData::Identifier(self.scan_identifier()?) }; } else if c == b'f' { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); break 'next if self.scan_keyword(b"false") { TokenData::Boolean(false) } else { TokenData::Identifier(self.scan_identifier()?) }; } else if c == b'n' { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); break 'next if self.scan_keyword(b"null") { TokenData::Null } else { @@ -425,9 +406,7 @@ impl<'a> JSON5Parser<'a> { || c == b'$' || c == b'\\' { - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); break 'next TokenData::Identifier(self.scan_identifier()?); } else if c >= 0x80 { // Multi-byte: check whitespace first, then identifier @@ -436,9 +415,7 @@ impl<'a> JSON5Parser<'a> { self.pos += usize::from(mb); continue 'next; } - self.token.loc = Loc { - start: i32::try_from(self.pos).expect("int cast"), - }; + self.start_token(); let Some(cp) = self.read_codepoint() else { return Err(ParseError::UnexpectedCharacter); }; @@ -505,6 +482,11 @@ impl<'a> JSON5Parser<'a> { // ── Parser ── fn parse_root(&mut self) -> Result { + // Token and error positions are `i32` `Loc`s, including the EOF token + // at `source.len()`. + if self.source.len() > i32::MAX as usize { + return Err(ParseError::DocumentTooLarge); + } self.scan()?; let result = self.parse_value()?; if !matches!(self.token.data, TokenData::Eof) { diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 5ddad10102cd..182bd7d183b9 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -1945,7 +1945,7 @@ impl PipelineTask { fn apply_pipeline(&self, d: &mut codecs::Decoded) -> Result<(), codecs::Error> { let p = &self.pipeline; if p.rotate != 0 { - let next = codecs::rotate(&d.rgba, d.width, d.height, u32::from(p.rotate))?; + let next = codecs::rotate(&d.rgba, d.width, d.height, p.rotate)?; // Assignment drops // the old `Vec`/owned buffer. d.rgba = next.rgba; @@ -2071,7 +2071,7 @@ fn apply_orientation( if t.rotate != 0 { // Swap pixel slots only — `next` carries no ICC profile, and the // one on `d` (set by decode) must survive EXIF auto-orient. - let next = codecs::rotate(&d.rgba, d.width, d.height, u32::from(t.rotate))?; + let next = codecs::rotate(&d.rgba, d.width, d.height, t.rotate)?; d.rgba = next.rgba; d.width = next.width; d.height = next.height; diff --git a/src/runtime/image/codecs.rs b/src/runtime/image/codecs.rs index 469b049ed1d8..42e09da28774 100644 --- a/src/runtime/image/codecs.rs +++ b/src/runtime/image/codecs.rs @@ -331,6 +331,16 @@ pub(crate) fn guard(w: u32, h: u32, max_pixels: u64) -> Result<(), Error> { Ok(()) } +/// A width or height reported by a C decoder (`c_int`); anything that is not +/// strictly positive means the header was rejected or is corrupt. +#[inline] +fn positive_dimension(v: c_int) -> Result { + match u32::try_from(v) { + Ok(d) if d != 0 => Ok(d), + _ => Err(Error::DecodeFailed), + } +} + pub(crate) struct Probe { pub format: Format, pub width: u32, @@ -367,11 +377,8 @@ pub(crate) fn probe(bytes: &[u8], max_pixels: u64) -> Result { let rw = unsafe { jpeg::tj3Get(handle.as_ptr(), jpeg::TJPARAM_JPEGWIDTH) }; // SAFETY: same handle invariant as above. let rh = unsafe { jpeg::tj3Get(handle.as_ptr(), jpeg::TJPARAM_JPEGHEIGHT) }; - if rw <= 0 || rh <= 0 { - return Err(Error::DecodeFailed); - } - w = u32::try_from(rw).expect("int cast"); - h = u32::try_from(rh).expect("int cast"); + w = positive_dimension(rw)?; + h = positive_dimension(rh)?; } Format::Webp => { let mut cw: c_int = 0; @@ -379,13 +386,11 @@ pub(crate) fn probe(bytes: &[u8], max_pixels: u64) -> Result { // SAFETY: (ptr,len) from a valid live slice; cw/ch are valid `*mut c_int` out-params. if unsafe { webp::WebPGetInfo(bytes.as_ptr(), bytes.len(), &raw mut cw, &raw mut ch) } == 0 - || cw <= 0 - || ch <= 0 { return Err(Error::DecodeFailed); } - w = u32::try_from(cw).expect("int cast"); - h = u32::try_from(ch).expect("int cast"); + w = positive_dimension(cw)?; + h = positive_dimension(ch)?; } Format::Bmp => { let ih = bmp::parse_header(bytes)?; @@ -655,6 +660,15 @@ pub(crate) fn modulate(rgba: &mut [u8], brightness: f32, saturation: f32) { unsafe { bun_image_modulate_rgba8(rgba.as_mut_ptr(), rgba.len(), brightness, saturation) } } +/// The highway kernels take `i32` dimensions. The static decoders reject any +/// side over 2³¹−1 (the PNG/BMP format limit; JPEG, WebP and GIF are far +/// smaller) and `do_resize` caps targets at 0x3FFFF, so this only fails for a +/// frame no kernel could address anyway. +#[inline] +fn kernel_dimension(v: u32) -> Result { + i32::try_from(v).map_err(|_| Error::TooManyPixels) +} + pub(crate) fn resize( src: &[u8], sw: u32, @@ -673,31 +687,25 @@ pub(crate) fn resize( Err(e) => return Err(e), } } + let (src_w, src_h) = (kernel_dimension(sw)?, kernel_dimension(sh)?); + let (dst_w, dst_h) = (kernel_dimension(dw)?, kernel_dimension(dh)?); // ONE allocation for output + the kernel's scratch arena (intermediate // dst_w×src_h×4 row buffer + spans/weights tables). Zero mallocs in the // C++; mimalloc here is faster than libc, and the over-allocation rounds // into the same size class as the row buffer alone. let out_sz: usize = (dw as usize) * (dh as usize) * 4; // SAFETY: pure FFI query; all args are by-value ints, no pointers. - let scratch_sz = unsafe { - bun_image_resize_scratch_size( - i32::try_from(sw).expect("int cast"), - i32::try_from(sh).expect("int cast"), - i32::try_from(dw).expect("int cast"), - i32::try_from(dh).expect("int cast"), - f as i32, - ) - }; + let scratch_sz = unsafe { bun_image_resize_scratch_size(src_w, src_h, dst_w, dst_h, f as i32) }; let mut block: Vec = vec![0u8; out_sz + scratch_sz]; // SAFETY: block has out_sz + scratch_sz bytes; dst at [0..out_sz), scratch at [out_sz..). let rc = unsafe { bun_image_resize_rgba8( src.as_ptr(), - i32::try_from(sw).expect("int cast"), - i32::try_from(sh).expect("int cast"), + src_w, + src_h, block.as_mut_ptr(), - i32::try_from(dw).expect("int cast"), - i32::try_from(dh).expect("int cast"), + dst_w, + dst_h, f as i32, block.as_mut_ptr().add(out_sz), ) @@ -713,7 +721,8 @@ pub(crate) fn resize( Ok(block) } -pub(crate) fn rotate(src: &[u8], w: u32, h: u32, degrees: u32) -> Result { +/// `degrees` is 90, 180 or 270 (callers validate it). +pub(crate) fn rotate(src: &[u8], w: u32, h: u32, degrees: u16) -> Result { let (dw, dh): (u32, u32) = if degrees == 90 || degrees == 270 { (h, w) } else { @@ -721,7 +730,12 @@ pub(crate) fn rotate(src: &[u8], w: u32, h: u32, degrees: u32) -> Result { return Ok(Decoded { rgba: out, @@ -734,17 +748,10 @@ pub(crate) fn rotate(src: &[u8], w: u32, h: u32, degrees: u32) -> Result return Err(e), } } + let (kw, kh) = (kernel_dimension(w)?, kernel_dimension(h)?); let mut out: Vec = vec![0u8; (dw as usize) * (dh as usize) * 4]; // SAFETY: src has w*h*4 bytes; out has dw*dh*4 bytes; degrees is multiple of 90. - unsafe { - bun_image_rotate_rgba8( - src.as_ptr(), - i32::try_from(w).expect("int cast"), - i32::try_from(h).expect("int cast"), - out.as_mut_ptr(), - i32::try_from(degrees).expect("int cast"), - ) - }; + unsafe { bun_image_rotate_rgba8(src.as_ptr(), kw, kh, out.as_mut_ptr(), i32::from(degrees)) }; Ok(Decoded { rgba: out, width: dw, @@ -762,16 +769,9 @@ pub(crate) fn flip(src: &[u8], w: u32, h: u32, horizontal: bool) -> Result return Err(e), } } + let (kw, kh) = (kernel_dimension(w)?, kernel_dimension(h)?); let mut out: Vec = vec![0u8; (w as usize) * (h as usize) * 4]; // SAFETY: src and out both have w*h*4 bytes. - unsafe { - bun_image_flip_rgba8( - src.as_ptr(), - i32::try_from(w).expect("int cast"), - i32::try_from(h).expect("int cast"), - out.as_mut_ptr(), - horizontal as i32, - ) - }; + unsafe { bun_image_flip_rgba8(src.as_ptr(), kw, kh, out.as_mut_ptr(), horizontal as i32) }; Ok(out) } diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index 01b514dbbe54..7a188c843bb1 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -1,6 +1,6 @@ import { Database } from "bun:sqlite"; import { describe, expect, test } from "bun:test"; -import { rmSync } from "fs"; +import { existsSync, rmSync } from "fs"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "path"; import { BundlerTestInput, itBundled as itBundledBase } from "./expectBundled"; @@ -1318,6 +1318,235 @@ test("compile --compile-executable-path rejects a Mach-O template whose __BUN se } }, 60_000); +// `bun build --compile --target=bun-linux-*` appends the application bundle to the base +// executable named by --compile-executable-path. To do that it walks the template's program +// header table, section header table and .shstrtab, patches the .bun section and rewrites a +// Nix store PT_INTERP. The templates below are the smallest ELF64 files with that shape: +// +// [ehdr][phdrs][.shstrtab][.interp?][.bun (8 bytes)] the writable PT_LOAD's file image +// [pad][section headers: null, .shstrtab, .bun, .interp?] +// +// Every field a `corruption` overrides is one the writer reads from the (arbitrary) template. +const ELF = { + EHDR: 64, + PHDR: 56, + SHDR: 64, + PT_LOAD: 1, + PT_INTERP: 3, + SHT_PROGBITS: 1, + SHT_STRTAB: 3, + VADDR: 0x400000n, + U64_MAX: (1n << 64n) - 1n, +}; + +interface ElfTemplateOptions { + interp?: string; // adds a PT_INTERP holding this path and a matching .interp section + interpOffset?: bigint; // corrupts that PT_INTERP's p_offset + bunOffset?: bigint; // corrupts .bun's sh_offset + phoff?: bigint; // e_phoff + shoff?: bigint; // e_shoff + shstrndx?: number; // e_shstrndx + shstrtabOffset?: bigint; // .shstrtab's sh_offset + loadFilesz?: bigint; // p_filesz of the writable PT_LOAD + loadMemsz?: bigint; // p_memsz of the writable PT_LOAD + pad?: number; // zero bytes between the PT_LOAD's file image and the section header table +} + +function elfTemplate(o: ElfTemplateOptions = {}): Buffer { + const { EHDR, PHDR, SHDR, VADDR } = ELF; + const interp = o.interp === undefined ? null : Buffer.from(o.interp + "\0", "latin1"); + const phnum = interp ? 2 : 1; + const shnum = interp ? 4 : 3; + const shstrtab = Buffer.from("\0.shstrtab\0.bun\0.interp\0", "latin1"); + const shstrtabOff = EHDR + PHDR * phnum; + const interpOff = shstrtabOff + shstrtab.length; + const bunOff = (interpOff + (interp?.length ?? 0) + 7) & ~7; + const loadEnd = bunOff + 8; + const shoff = (loadEnd + (o.pad ?? 0) + 7) & ~7; + const buf = Buffer.alloc(shoff + SHDR * shnum); + + buf.write("\x7fELF", 0, "latin1"); + buf[4] = 2; // ELFCLASS64 + buf[5] = 1; // ELFDATA2LSB + buf[6] = 1; // EV_CURRENT + buf.writeUInt16LE(2, 16); // e_type = ET_EXEC + buf.writeUInt16LE(62, 18); // e_machine = EM_X86_64 + buf.writeUInt32LE(1, 20); // e_version + buf.writeBigUInt64LE(VADDR, 24); // e_entry + buf.writeBigUInt64LE(o.phoff ?? BigInt(EHDR), 32); // e_phoff + buf.writeBigUInt64LE(o.shoff ?? BigInt(shoff), 40); // e_shoff + buf.writeUInt16LE(EHDR, 52); // e_ehsize + buf.writeUInt16LE(PHDR, 54); // e_phentsize + buf.writeUInt16LE(phnum, 56); // e_phnum + buf.writeUInt16LE(SHDR, 58); // e_shentsize + buf.writeUInt16LE(shnum, 60); // e_shnum + buf.writeUInt16LE(o.shstrndx ?? 1, 62); // e_shstrndx + + const phdr = (i: number, type: number, flags: number, offset: bigint, filesz: bigint, memsz: bigint) => { + const p = EHDR + i * PHDR; + buf.writeUInt32LE(type, p); + buf.writeUInt32LE(flags, p + 4); + buf.writeBigUInt64LE(offset, p + 8); + buf.writeBigUInt64LE(BigInt.asUintN(64, VADDR + offset), p + 16); // p_vaddr + buf.writeBigUInt64LE(BigInt.asUintN(64, VADDR + offset), p + 24); // p_paddr + buf.writeBigUInt64LE(filesz, p + 32); + buf.writeBigUInt64LE(memsz, p + 40); + buf.writeBigUInt64LE(0x1000n, p + 48); // p_align + }; + const PF_R = 4; + const PF_W = 2; + phdr(0, ELF.PT_LOAD, PF_R | PF_W, 0n, o.loadFilesz ?? BigInt(loadEnd), o.loadMemsz ?? BigInt(loadEnd)); + if (interp) { + const size = BigInt(interp.length); + phdr(1, ELF.PT_INTERP, PF_R, o.interpOffset ?? BigInt(interpOff), size, size); + interp.copy(buf, interpOff); + } + + shstrtab.copy(buf, shstrtabOff); + + const shdr = (i: number, name: number, type: number, alloc: boolean, offset: bigint, size: bigint) => { + const s = shoff + i * SHDR; + buf.writeUInt32LE(name, s); + buf.writeUInt32LE(type, s + 4); + buf.writeBigUInt64LE(alloc ? 2n : 0n, s + 8); // sh_flags = SHF_ALLOC + buf.writeBigUInt64LE(alloc ? VADDR + offset : 0n, s + 16); // sh_addr + buf.writeBigUInt64LE(offset, s + 24); + buf.writeBigUInt64LE(size, s + 32); + buf.writeBigUInt64LE(1n, s + 48); // sh_addralign + }; + shdr(1, 1, ELF.SHT_STRTAB, false, o.shstrtabOffset ?? BigInt(shstrtabOff), BigInt(shstrtab.length)); + shdr(2, 11, ELF.SHT_PROGBITS, true, BigInt(bunOff), 8n); + // Corrupt only sh_offset: sh_addr still has to fall inside the writable PT_LOAD for the + // writer to get as far as using the offset. + if (o.bunOffset !== undefined) buf.writeBigUInt64LE(o.bunOffset, shoff + 2 * SHDR + 24); + if (interp) shdr(3, 16, ELF.SHT_PROGBITS, true, BigInt(interpOff), BigInt(interp.length)); + return buf; +} + +/** PT_INTERP and the `.interp` section header as written to a compiled output. */ +function readElfInterp(buf: Buffer): { interp: string; p_filesz: number; sh_size: number | null } { + const { PHDR, SHDR } = ELF; + const cstr = (offset: number, size: number) => { + const bytes = buf.subarray(offset, offset + size); + const nul = bytes.indexOf(0); + return bytes.subarray(0, nul === -1 ? bytes.length : nul).toString("latin1"); + }; + let interp: string | null = null; + let p_filesz = 0; + const phoff = Number(buf.readBigUInt64LE(32)); + for (let i = 0; i < buf.readUInt16LE(56); i++) { + const p = phoff + i * PHDR; + if (buf.readUInt32LE(p) !== ELF.PT_INTERP) continue; + p_filesz = Number(buf.readBigUInt64LE(p + 32)); + interp = cstr(Number(buf.readBigUInt64LE(p + 8)), p_filesz); + } + if (interp === null) throw new Error("output has no PT_INTERP"); + + let sh_size: number | null = null; + const shoff = Number(buf.readBigUInt64LE(40)); + const shnum = buf.readUInt16LE(60); + const shstrtab = shoff + buf.readUInt16LE(62) * SHDR; + const namesOff = Number(buf.readBigUInt64LE(shstrtab + 24)); + const namesSize = Number(buf.readBigUInt64LE(shstrtab + 32)); + for (let i = 0; i < shnum; i++) { + const s = shoff + i * SHDR; + const nameOff = buf.readUInt32LE(s); + if (cstr(namesOff + nameOff, namesSize - nameOff) === ".interp") sh_size = Number(buf.readBigUInt64LE(s + 32)); + } + return { interp, p_filesz, sh_size }; +} + +async function compileWithElfTemplate(cwd: string, name: string, template: Buffer) { + const templatePath = join(cwd, `template-${name}`); + await Bun.write(templatePath, template); + const outfile = join(cwd, `out-${name}`); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + "--target=bun-linux-x64", + "--compile-executable-path", + templatePath, + join(cwd, "entry.js"), + "--outfile", + outfile, + ], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const output = (await Bun.file(outfile).exists()) ? Buffer.from(await Bun.file(outfile).arrayBuffer()) : null; + return { name, stderr, exitCode, output }; +} + +const NIX_INTERP = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-glibc-2.40-1/lib/ld-linux-x86-64.so.2"; + +test("compile --compile-executable-path rejects an ELF template whose headers point outside the file", async () => { + using dir = tempDir("compile-elf-template-bounds", { + "entry.js": `console.log("compiled-from-template");`, + }); + const cwd = String(dir); + const { U64_MAX } = ELF; + + const corrupt: [name: string, template: Buffer][] = [ + ["bun-section-past-eof", elfTemplate({ bunOffset: 1n << 40n })], + ["phdr-table-past-eof", elfTemplate({ phoff: 1n << 40n })], + ["phdr-table-wraps", elfTemplate({ phoff: U64_MAX - 7n })], + ["shdr-table-wraps", elfTemplate({ shoff: U64_MAX - 7n })], + ["shstrndx-past-shnum", elfTemplate({ shstrndx: 3 })], + ["shstrtab-wraps", elfTemplate({ shstrtabOffset: U64_MAX - 3n })], + ["load-memsz-wraps", elfTemplate({ loadMemsz: U64_MAX })], + // p_filesz > p_memsz: the appended data would land inside the segment's existing bytes. + ["load-filesz-over-memsz", elfTemplate({ pad: 0x2000, loadFilesz: 0x1800n })], + ]; + // The templates are a few hundred bytes, so unlike compiles against a real bun binary + // these are cheap enough to run at once. + const results = await Promise.all(corrupt.map(([name, template]) => compileWithElfTemplate(cwd, name, template))); + for (const { name, stderr, exitCode, output } of results) { + // Every corrupt template is reported as a clean error... + expect({ name, stderr }).toEqual({ name, stderr: expect.stringContaining("InvalidElfFile") }); + // ...produces no executable... + expect({ name, output }).toEqual({ name, output: null }); + // ...and exits with a normal failure code instead of crashing. + expect({ name, exitCode }).toEqual({ name, exitCode: 1 }); + } + + // The same template with consistent headers is accepted, and so is one whose PT_INTERP + // points outside the file: the interpreter rewrite is best-effort and leaves it alone. + for (const [name, template] of [ + ["good", elfTemplate()], + ["interp-wraps", elfTemplate({ interp: NIX_INTERP, interpOffset: U64_MAX - 3n })], + ] as const) { + const { stderr, exitCode, output } = await compileWithElfTemplate(cwd, name, template); + expect({ name, stderr }).toEqual({ name, stderr: expect.not.stringContaining("error:") }); + expect({ name, embedded: output?.includes("compiled-from-template") }).toEqual({ name, embedded: true }); + expect({ name, exitCode }).toEqual({ name, exitCode: 0 }); + } +}); + +// On Nix/Guix hosts the FHS loader path is a stub, so the rewrite is skipped there (#29290). +test.skipIf(existsSync("/etc/NIXOS") || existsSync("/gnu/store"))( + "compile --compile-executable-path rewrites a Nix store PT_INTERP and the .interp section header", + async () => { + using dir = tempDir("compile-elf-template-interp", { + "entry.js": `console.log("compiled-from-template");`, + }); + const { stderr, exitCode, output } = await compileWithElfTemplate( + String(dir), + "nix-interp", + elfTemplate({ interp: NIX_INTERP }), + ); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + const ldso = "/lib64/ld-linux-x86-64.so.2"; + expect(readElfInterp(output!)).toEqual({ interp: ldso, p_filesz: ldso.length + 1, sh_size: ldso.length + 1 }); + expect(output!.includes("compiled-from-template")).toBe(true); + }, +); + test("compile --compile-executable-path rejects a template shorter than the executable-format header", async () => { // `--compile-executable-path` accepts an arbitrary file. A file shorter than the target // format's fixed header (or one whose header advertises more load-command bytes than the diff --git a/test/internal/source-lints/int-cast-expect-limits.json b/test/internal/source-lints/int-cast-expect-limits.json new file mode 100644 index 000000000000..035c19f2e53b --- /dev/null +++ b/test/internal/source-lints/int-cast-expect-limits.json @@ -0,0 +1,275 @@ +{ + "src/ast/lib.rs": 12, + "src/ast/nodes.rs": 4, + "src/ast/runtime.rs": 1, + "src/bun_core/Progress.rs": 2, + "src/bun_core/bounded_array.rs": 2, + "src/bun_core/fmt.rs": 1, + "src/bun_core/string/SmolStr.rs": 2, + "src/bun_core/string/identifier.rs": 2, + "src/bun_core/string/immutable.rs": 1, + "src/bun_core/string/immutable/unicode.rs": 2, + "src/bundler/AstBuilder.rs": 1, + "src/bundler/LinkerContext.rs": 7, + "src/bundler/ParseTask.rs": 1, + "src/bundler/bundle_v2.rs": 13, + "src/bundler/linker.rs": 1, + "src/bundler/linker_context/MetafileBuilder.rs": 8, + "src/bundler/linker_context/OutputFileListBuilder.rs": 3, + "src/bundler/linker_context/computeChunks.rs": 4, + "src/bundler/linker_context/computeCrossChunkDependencies.rs": 1, + "src/bundler/linker_context/findAllImportedPartsInJSOrder.rs": 1, + "src/bundler/linker_context/generateChunksInParallel.rs": 5, + "src/bundler/linker_context/generateCompileResultForHtmlChunk.rs": 5, + "src/bundler/linker_context/scanImportsAndExports.rs": 1, + "src/bundler/linker_context/writeOutputFilesToDisk.rs": 1, + "src/bundler/options.rs": 1, + "src/cares_sys/c_ares.rs": 2, + "src/collections/StaticHashMap.rs": 2, + "src/collections/bit_set.rs": 9, + "src/collections/hive_array.rs": 1, + "src/crash_handler/lib.rs": 5, + "src/css/css_parser.rs": 14, + "src/css/error.rs": 2, + "src/css/printer.rs": 10, + "src/css/properties/css_modules.rs": 1, + "src/css/properties/transition.rs": 1, + "src/css/rules/font_face.rs": 2, + "src/css/selectors/builder.rs": 1, + "src/css/selectors/parser.rs": 1, + "src/css_jsc/color_js.rs": 13, + "src/exe_format/macho.rs": 6, + "src/exe_format/pe.rs": 3, + "src/glob/GlobWalker.rs": 13, + "src/glob/matcher.rs": 1, + "src/http/HTTPContext.rs": 4, + "src/http/HTTPThread.rs": 1, + "src/http/ProxyTunnel.rs": 2, + "src/http/SendFile.rs": 5, + "src/http/h2_client/ClientSession.rs": 2, + "src/http/h2_client/dispatch.rs": 7, + "src/http/h2_client/encode.rs": 4, + "src/http/h3_client/ClientContext.rs": 2, + "src/http/h3_client/encode.rs": 2, + "src/http/lib.rs": 2, + "src/http_jsc/websocket_client.rs": 4, + "src/http_jsc/websocket_client/WebSocketProxyTunnel.rs": 2, + "src/http_jsc/websocket_client/WebSocketUpgradeClient.rs": 3, + "src/http_types/URLPath.rs": 7, + "src/ini/lib.rs": 3, + "src/install/PackageInstall.rs": 3, + "src/install/PackageManager.rs": 2, + "src/install/PackageManager/PackageManagerDirectories.rs": 1, + "src/install/PackageManager/PopulateManifestCache.rs": 1, + "src/install/PackageManager/install_with_manager.rs": 1, + "src/install/PackageManager/runTasks.rs": 2, + "src/install/PackageManager/security_scanner.rs": 12, + "src/install/TarballStream.rs": 14, + "src/install/hosted_git_info.rs": 10, + "src/install/isolated_install.rs": 14, + "src/install/isolated_install/Installer.rs": 3, + "src/install/lifecycle_script_runner.rs": 1, + "src/install/lockfile.rs": 5, + "src/install/lockfile/Package.rs": 2, + "src/install/lockfile/Package/Scripts.rs": 6, + "src/install/lockfile/bun.lock.rs": 3, + "src/install/lockfile/lockfile_json_stringify_for_debugging.rs": 2, + "src/install/lockfile/printer/tree_printer.rs": 4, + "src/install/npm.rs": 2, + "src/install/patch_install.rs": 1, + "src/install/pnpm.rs": 8, + "src/install/windows-shim/BinLinkingShim.rs": 2, + "src/install/windows-shim/bun_shim_impl.rs": 2, + "src/install/yarn.rs": 16, + "src/io/ParentDeathWatchdog.rs": 4, + "src/io/PipeReader.rs": 5, + "src/io/PipeWriter.rs": 1, + "src/io/lib.rs": 6, + "src/io/posix_event_loop.rs": 12, + "src/js_parser/lexer.rs": 20, + "src/js_parser/lower/lower_esm_exports_hmr.rs": 2, + "src/js_parser/p.rs": 6, + "src/js_parser/parse/parse_entry.rs": 1, + "src/js_parser/parse/parse_prefix.rs": 1, + "src/js_parser/parser.rs": 1, + "src/js_parser/visit/mod.rs": 1, + "src/js_parser/visit/visit_binary.rs": 1, + "src/js_parser/visit/visit_expr.rs": 4, + "src/js_printer/lib.rs": 6, + "src/js_printer/renamer.rs": 2, + "src/jsc/BunCPUProfiler.rs": 2, + "src/jsc/BunHeapProfiler.rs": 1, + "src/jsc/ConsoleObject.rs": 3, + "src/jsc/JSGlobalObject.rs": 4, + "src/jsc/PosixSignalHandle.rs": 1, + "src/jsc/RuntimeTranspilerCache.rs": 2, + "src/jsc/ZigStackTrace.rs": 3, + "src/jsc/array_buffer.rs": 7, + "src/jsc/bindgen.rs": 2, + "src/jsc/btjs.rs": 1, + "src/jsc/event_loop.rs": 5, + "src/jsc/virtual_machine_exports.rs": 1, + "src/libarchive/lib.rs": 7, + "src/md/ansi_renderer.rs": 11, + "src/md/blocks.rs": 2, + "src/md/containers.rs": 1, + "src/md/helpers.rs": 1, + "src/md/inlines.rs": 2, + "src/md/line_analysis.rs": 1, + "src/parsers/yaml.rs": 3, + "src/patch/lib.rs": 1, + "src/perf/lib.rs": 1, + "src/perf/system_timer.rs": 1, + "src/picohttp/lib.rs": 2, + "src/resolver/lib.rs": 1, + "src/resolver/package_json.rs": 5, + "src/resolver/resolver.rs": 2, + "src/runtime/JSONLineBuffer.rs": 1, + "src/runtime/allocators/LinuxMemFdAllocator.rs": 2, + "src/runtime/api/Archive.rs": 8, + "src/runtime/api/BunObject.rs": 3, + "src/runtime/api/MarkdownObject.rs": 1, + "src/runtime/api/YAMLObject.rs": 1, + "src/runtime/api/bun/Terminal.rs": 9, + "src/runtime/api/bun/h2_frame_parser.rs": 38, + "src/runtime/api/bun/js_bun_spawn_bindings.rs": 6, + "src/runtime/api/bun/spawn/stdio.rs": 2, + "src/runtime/api/cron_parser.rs": 8, + "src/runtime/bake/DevServer.rs": 16, + "src/runtime/bake/FrameworkRouter.rs": 8, + "src/runtime/bake/bake_body.rs": 1, + "src/runtime/bake/dev_server/assets.rs": 2, + "src/runtime/bake/dev_server/incremental_graph.rs": 1, + "src/runtime/bake/dev_server/mod.rs": 1, + "src/runtime/bake/dev_server/serialized_failure.rs": 5, + "src/runtime/bake/dev_server/source_map_store.rs": 5, + "src/runtime/bake/production.rs": 16, + "src/runtime/cli/build_command.rs": 2, + "src/runtime/cli/bunx_command.rs": 4, + "src/runtime/cli/create_command.rs": 4, + "src/runtime/cli/init_command.rs": 3, + "src/runtime/cli/install_completions_command.rs": 1, + "src/runtime/cli/mod.rs": 1, + "src/runtime/cli/open.rs": 4, + "src/runtime/cli/pack_command.rs": 12, + "src/runtime/cli/pm_trusted_command.rs": 2, + "src/runtime/cli/publish_command.rs": 3, + "src/runtime/cli/run_command.rs": 2, + "src/runtime/cli/update_interactive_command.rs": 2, + "src/runtime/cli/upgrade_command.rs": 5, + "src/runtime/cli/why_command.rs": 2, + "src/runtime/crypto/CryptoHasher.rs": 2, + "src/runtime/crypto/PBKDF2.rs": 1, + "src/runtime/ffi/FFIObject.rs": 2, + "src/runtime/ffi/ffi_body.rs": 3, + "src/runtime/image/Image.rs": 8, + "src/runtime/image/backend_wic.rs": 9, + "src/runtime/image/codec_bmp.rs": 1, + "src/runtime/image/codec_jpeg.rs": 8, + "src/runtime/image/codec_webp.rs": 7, + "src/runtime/image/quantize.rs": 10, + "src/runtime/image/thumbhash.rs": 1, + "src/runtime/ipc.rs": 4, + "src/runtime/napi/napi_body.rs": 1, + "src/runtime/node/assert/myers_diff.rs": 4, + "src/runtime/node/dir_iterator.rs": 2, + "src/runtime/node/net/BlockList.rs": 3, + "src/runtime/node/node_crypto_binding.rs": 4, + "src/runtime/node/node_fs.rs": 11, + "src/runtime/node/node_fs_stat_watcher.rs": 4, + "src/runtime/node/node_net_binding.rs": 1, + "src/runtime/node/node_os.rs": 14, + "src/runtime/node/node_zlib_binding.rs": 2, + "src/runtime/node/path.rs": 32, + "src/runtime/node/util/parse_args.rs": 5, + "src/runtime/node/zlib/NativeBrotli.rs": 3, + "src/runtime/node/zlib/NativeZlib.rs": 6, + "src/runtime/node/zlib/NativeZstd.rs": 3, + "src/runtime/server/DirectoryRoute.rs": 1, + "src/runtime/server/FileResponseStream.rs": 3, + "src/runtime/server/FileRoute.rs": 1, + "src/runtime/server/NodeHTTPResponse.rs": 3, + "src/runtime/server/ServerConfig.rs": 2, + "src/runtime/server/server_body.rs": 2, + "src/runtime/shell/shell_body.rs": 1, + "src/runtime/shell/subproc.rs": 2, + "src/runtime/socket/SocketAddress.rs": 2, + "src/runtime/socket/UpgradedDuplex.rs": 2, + "src/runtime/socket/WindowsNamedPipe.rs": 3, + "src/runtime/socket/socket_body.rs": 18, + "src/runtime/socket/tls_socket_functions.rs": 10, + "src/runtime/socket/udp_socket.rs": 4, + "src/runtime/timer/Timer.rs": 1, + "src/runtime/timer/mod.rs": 2, + "src/runtime/valkey_jsc/js_valkey.rs": 1, + "src/runtime/valkey_jsc/valkey.rs": 3, + "src/runtime/webcore/Blob.rs": 12, + "src/runtime/webcore/ByteBlobLoader.rs": 1, + "src/runtime/webcore/ByteStream.rs": 1, + "src/runtime/webcore/Crypto.rs": 1, + "src/runtime/webcore/Response.rs": 2, + "src/runtime/webcore/blob/copy_file.rs": 18, + "src/runtime/webcore/blob/read_file.rs": 4, + "src/runtime/webcore/blob/write_file.rs": 3, + "src/runtime/webcore/fetch.rs": 1, + "src/runtime/webcore/fetch/FetchTasklet.rs": 1, + "src/runtime/webcore/s3/list_objects.rs": 2, + "src/s3_signing/credentials.rs": 9, + "src/semver/Version.rs": 2, + "src/shell_parser/braces.rs": 4, + "src/shell_parser/parse.rs": 10, + "src/sourcemap/InternalSourceMap.rs": 4, + "src/sourcemap/LineOffsetTable.rs": 5, + "src/sourcemap/Mapping.rs": 2, + "src/sourcemap/ParsedSourceMap.rs": 2, + "src/sourcemap/lib.rs": 2, + "src/sourcemap_jsc/CodeCoverage.rs": 24, + "src/sourcemap_jsc/JSSourceMap.rs": 4, + "src/spawn/process.rs": 14, + "src/spawn_sys/posix_spawn.rs": 5, + "src/sql/mysql/protocol/Auth.rs": 2, + "src/sql/mysql/protocol/EncodeInt.rs": 14, + "src/sql/mysql/protocol/HandshakeResponse41.rs": 1, + "src/sql/mysql/protocol/NewReader.rs": 1, + "src/sql/mysql/protocol/NewWriter.rs": 1, + "src/sql/mysql/protocol/PacketHeader.rs": 3, + "src/sql/postgres/PostgresProtocol.rs": 1, + "src/sql/postgres/protocol/DataRow.rs": 4, + "src/sql/shared/StackReader.rs": 1, + "src/sql_jsc/mysql/JSMySQLConnection.rs": 3, + "src/sql_jsc/mysql/MySQLConnection.rs": 7, + "src/sql_jsc/mysql/MySQLValue.rs": 9, + "src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs": 2, + "src/sql_jsc/mysql/protocol/ResultSet.rs": 5, + "src/sql_jsc/postgres/DataCell.rs": 3, + "src/sql_jsc/postgres/PostgresRequest.rs": 1, + "src/sql_jsc/postgres/PostgresSQLConnection.rs": 6, + "src/sql_jsc/postgres/SASL.rs": 1, + "src/standalone_graph/StandaloneModuleGraph.rs": 2, + "src/sys/Error.rs": 2, + "src/sys/lib.rs": 1, + "src/sys/sys_uv.rs": 8, + "src/sys/windows/mod.rs": 3, + "src/sys_jsc/fd_jsc.rs": 2, + "src/threading/Futex.rs": 1, + "src/url/lib.rs": 3, + "src/uws/lib.rs": 9, + "src/uws_sys/App.rs": 1, + "src/uws_sys/Loop.rs": 2, + "src/uws_sys/Response.rs": 1, + "src/uws_sys/Timer.rs": 1, + "src/uws_sys/h3.rs": 2, + "src/uws_sys/quic/Header.rs": 2, + "src/uws_sys/quic/Socket.rs": 1, + "src/uws_sys/quic/Stream.rs": 2, + "src/uws_sys/udp.rs": 2, + "src/uws_sys/us_socket_t.rs": 10, + "src/uws_sys/vtable.rs": 2, + "src/valkey/valkey_protocol.rs": 12, + "src/watcher/INotifyWatcher.rs": 4, + "src/watcher/Watcher.rs": 1, + "src/watcher/WatcherTrace.rs": 1, + "src/which/lib.rs": 1, + "src/wyhash/lib.rs": 1, + "src/zlib/lib.rs": 1 +} diff --git a/test/internal/source-lints/int-cast-expects.test.ts b/test/internal/source-lints/int-cast-expects.test.ts new file mode 100644 index 000000000000..2d9e1ffd0e05 --- /dev/null +++ b/test/internal/source-lints/int-cast-expects.test.ts @@ -0,0 +1,91 @@ +// Per-file inventory of `.expect("int cast")` sites in the Rust sources. +// +// `T::try_from(x).expect("int cast")` is the mechanical translation of Zig's +// `@intCast`. Zig only trapped on it in Debug/ReleaseSafe builds; the Rust port +// builds with `panic = "abort"`, so every one of these is a crash in the shipped +// binary for any value that does not fit, and several have turned out to be +// reachable from user input (bun:ffi offsets and lengths, --cpu-prof-interval, +// gunzipSync output over 4 GiB, an oversized http2 origin, ...). This test pins +// the count per file so it can only go down. +// +// To remove a site, rewrite it as one of: +// - a checked conversion that returns the function's error (or throws the +// RangeError / validation error its neighbours throw), or +// - a plain conversion (`From`, or `as` for a widening) where the source +// type or a range check right above makes it visibly infallible. +// +// If this fails because a count went UP: rewrite the new site as above rather +// than raising its limit. If it fails because a count went DOWN: you removed +// sites, so lower the limits to match: +// bun ./test/internal/source-lints/int-cast-expects.test.ts --update + +import { file } from "bun"; +import { describe, test } from "bun:test"; +import { realpathSync } from "fs"; +import path from "path"; +import { globAllSources } from "../../../scripts/glob-sources.ts"; + +const SITE = /\.expect\(\s*"int cast"\s*\)/g; + +const root = path.resolve(import.meta.dir, "..", "..", ".."); +const LIMITS = import.meta.dir + "/int-cast-expect-limits.json"; +const UPDATE = "bun ./test/internal/source-lints/int-cast-expects.test.ts --update"; + +// Only count files tracked in HEAD: editors and `git stash` round-trips can +// leave stray `.rs` files in the working tree, and those must not fail the +// ratchet. CI runs against the committed tree, so every real file is covered. +const tracked: Set | null = (() => { + const r = Bun.spawnSync({ + cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"], + stdout: "pipe", + stderr: "ignore", + }); + if (!r.success) return null; + return new Set(r.stdout.toString().split("\0").filter(Boolean)); +})(); + +const counts: Record = {}; +for (const abs of globAllSources().rust.filter(p => p.endsWith(".rs"))) { + const source = path.relative(root, abs).replaceAll(path.sep, "/"); + // `src/cli` is a symlink into `src/runtime/cli`; count each file once + // under its canonical path. + if (path.relative(root, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue; + if (tracked !== null && !tracked.has(source)) continue; + const content = await file(abs).text(); + if (!content.includes("int cast")) continue; + // Whole-file scan so a call rustfmt wrapped onto its own line still counts; + // full-line `//` comments are stripped so a commented-out site does not. + const stripped = content.replace(/^\s*\/\/.*$/gm, ""); + const n = [...stripped.matchAll(SITE)].length; + if (n > 0) counts[source] = n; +} + +if (process.argv.includes("--update")) { + const sorted = Object.fromEntries(Object.entries(counts).sort(([a], [b]) => (a < b ? -1 : 1))); + await Bun.write(LIMITS, JSON.stringify(sorted, null, 2) + "\n"); + const total = Object.values(sorted).reduce((a, b) => a + b, 0); + console.log(`Wrote ${Object.keys(sorted).length} files (${total} sites) to ${path.basename(LIMITS)}`); + process.exit(0); +} + +const limits: Record = await Bun.file(LIMITS).json(); + +describe('.expect("int cast") sites', () => { + const files = [...new Set([...Object.keys(limits), ...Object.keys(counts)])].sort(); + test.each(files)("%s", source => { + const limit = limits[source] ?? 0; + const count = counts[source] ?? 0; + if (count > limit) { + throw new Error( + `${source} has ${count} .expect("int cast") sites, up from ${limit}. Each one aborts the process on a value ` + + `that does not fit. Return an error from the failed conversion instead, or use a plain conversion where the ` + + `value is already range-checked (see the header of int-cast-expects.test.ts).`, + ); + } + if (count < limit) { + throw new Error( + `${source} has ${count} .expect("int cast") sites, down from ${limit}. Lower the limit so they cannot come back: ${UPDATE}`, + ); + } + }); +}); From cc3327ff72a52b649b02d1a550bfe3ac83bba332 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:53:56 +0000 Subject: [PATCH 2/9] test: mirror the runtime's Nix host check when gating the PT_INTERP rewrite test host_uses_nix_store_interpreter() also treats a bun whose own PT_INTERP is a store path as a Nix host, so the skip condition reads bunExe()'s first page the same way the sibling patchelf tests do. --- test/bundler/bundler_compile.test.ts | 78 ++++++++++++++++++---------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index 7a188c843bb1..e2f1836433e9 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -1,7 +1,7 @@ import { Database } from "bun:sqlite"; import { describe, expect, test } from "bun:test"; -import { existsSync, rmSync } from "fs"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { closeSync, existsSync, openSync, readSync, rmSync } from "fs"; +import { bunEnv, bunExe, isLinux, isWindows, tempDir } from "harness"; import { join } from "path"; import { BundlerTestInput, itBundled as itBundledBase } from "./expectBundled"; @@ -1423,37 +1423,60 @@ function elfTemplate(o: ElfTemplateOptions = {}): Buffer { return buf; } -/** PT_INTERP and the `.interp` section header as written to a compiled output. */ -function readElfInterp(buf: Buffer): { interp: string; p_filesz: number; sh_size: number | null } { - const { PHDR, SHDR } = ELF; - const cstr = (offset: number, size: number) => { - const bytes = buf.subarray(offset, offset + size); - const nul = bytes.indexOf(0); - return bytes.subarray(0, nul === -1 ? bytes.length : nul).toString("latin1"); - }; - let interp: string | null = null; - let p_filesz = 0; +function elfCString(buf: Buffer, offset: number, size: number): string { + const bytes = buf.subarray(offset, offset + size); + const nul = bytes.indexOf(0); + return bytes.subarray(0, nul === -1 ? bytes.length : nul).toString("latin1"); +} + +/** PT_INTERP of an ELF64 image. The first page of a real binary is enough. */ +function readPtInterp(buf: Buffer): { interp: string; p_filesz: number } | null { + if (buf.length < ELF.EHDR || buf.toString("latin1", 0, 4) !== "\x7fELF") return null; const phoff = Number(buf.readBigUInt64LE(32)); for (let i = 0; i < buf.readUInt16LE(56); i++) { - const p = phoff + i * PHDR; + const p = phoff + i * ELF.PHDR; if (buf.readUInt32LE(p) !== ELF.PT_INTERP) continue; - p_filesz = Number(buf.readBigUInt64LE(p + 32)); - interp = cstr(Number(buf.readBigUInt64LE(p + 8)), p_filesz); + const p_filesz = Number(buf.readBigUInt64LE(p + 32)); + return { interp: elfCString(buf, Number(buf.readBigUInt64LE(p + 8)), p_filesz), p_filesz }; } - if (interp === null) throw new Error("output has no PT_INTERP"); + return null; +} - let sh_size: number | null = null; +/** `sh_size` of the `.interp` section, looked up through the (relocated) section header table. */ +function readInterpSectionSize(buf: Buffer): number | null { const shoff = Number(buf.readBigUInt64LE(40)); - const shnum = buf.readUInt16LE(60); - const shstrtab = shoff + buf.readUInt16LE(62) * SHDR; + const shstrtab = shoff + buf.readUInt16LE(62) * ELF.SHDR; const namesOff = Number(buf.readBigUInt64LE(shstrtab + 24)); const namesSize = Number(buf.readBigUInt64LE(shstrtab + 32)); - for (let i = 0; i < shnum; i++) { - const s = shoff + i * SHDR; + for (let i = 0; i < buf.readUInt16LE(60); i++) { + const s = shoff + i * ELF.SHDR; const nameOff = buf.readUInt32LE(s); - if (cstr(namesOff + nameOff, namesSize - nameOff) === ".interp") sh_size = Number(buf.readBigUInt64LE(s + 32)); + if (elfCString(buf, namesOff + nameOff, namesSize - nameOff) === ".interp") { + return Number(buf.readBigUInt64LE(s + 32)); + } + } + return null; +} + +// Mirror of host_uses_nix_store_interpreter() in src/exe_format/elf.rs: on a Nix/Guix host the +// FHS loader path is a stub, so the rewrite is skipped there (#29290). The runtime also treats +// a bun whose own PT_INTERP is a store path as such a host, so this has to as well, or the +// rewrite test fails on a non-NixOS machine whose bun was installed through Nix. +function hostLooksNix(): boolean { + if (!isLinux) return false; + if (existsSync("/etc/NIXOS") || existsSync("/gnu/store")) return true; + try { + const fd = openSync(bunExe(), "r"); + try { + const head = Buffer.alloc(4096); + const interp = readPtInterp(head.subarray(0, readSync(fd, head, 0, head.length, 0)))?.interp ?? ""; + return interp.startsWith("/nix/store/") || interp.startsWith("/gnu/store/"); + } finally { + closeSync(fd); + } + } catch { + return false; } - return { interp, p_filesz, sh_size }; } async function compileWithElfTemplate(cwd: string, name: string, template: Buffer) { @@ -1527,8 +1550,7 @@ test("compile --compile-executable-path rejects an ELF template whose headers po } }); -// On Nix/Guix hosts the FHS loader path is a stub, so the rewrite is skipped there (#29290). -test.skipIf(existsSync("/etc/NIXOS") || existsSync("/gnu/store"))( +test.skipIf(hostLooksNix())( "compile --compile-executable-path rewrites a Nix store PT_INTERP and the .interp section header", async () => { using dir = tempDir("compile-elf-template-interp", { @@ -1542,7 +1564,11 @@ test.skipIf(existsSync("/etc/NIXOS") || existsSync("/gnu/store"))( expect(stderr).not.toContain("error:"); expect(exitCode).toBe(0); const ldso = "/lib64/ld-linux-x86-64.so.2"; - expect(readElfInterp(output!)).toEqual({ interp: ldso, p_filesz: ldso.length + 1, sh_size: ldso.length + 1 }); + expect({ ...readPtInterp(output!), sh_size: readInterpSectionSize(output!) }).toEqual({ + interp: ldso, + p_filesz: ldso.length + 1, + sh_size: ldso.length + 1, + }); expect(output!.includes("compiled-from-template")).toBe(true); }, ); From 84a007e8ed82ff56e8ba3ae7126cd1d8bc6321e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:01:49 +0000 Subject: [PATCH 3/9] Shorten the comments on the new helpers --- src/exe_format/elf.rs | 27 +++++++-------------------- src/parsers/json5.rs | 6 ++---- src/runtime/image/codecs.rs | 9 +++------ 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/src/exe_format/elf.rs b/src/exe_format/elf.rs index d3221cfaa721..01a23dc498c9 100644 --- a/src/exe_format/elf.rs +++ b/src/exe_format/elf.rs @@ -65,10 +65,7 @@ impl ElfFile { } let ehdr = read_ehdr(&self.data); - - // --compile-executable-path accepts arbitrary files, and this rewrite is - // best-effort: a header table or PT_INTERP that does not fit in the - // file leaves the interpreter alone instead of failing the build. + // Best-effort: a template whose headers do not fit in the file is left alone. let Ok(phdrs) = phdr_table(&self.data, ehdr) else { return; }; @@ -230,8 +227,6 @@ impl ElfFile { return Err(ElfError::NoWritableLoadSegment); }; - // Every file offset taken from the template's headers is checked - // against the file before the layout below is computed from it. let old_rw_file_end = file_range(&self.data, rw_phdr.p_offset, rw_phdr.p_filesz)?.end; let bun_vaddr_slot = file_range(&self.data, bun_section.file_offset, header_size)?; let old_shdrs = shdr_table(&self.data, ehdr)?; @@ -290,14 +285,10 @@ impl ElfFile { // because that file range now lives inside the extended RW PT_LOAD. // Leaving it in place would mmap it into what was previously BSS // (zero-initialized statics), corrupting the process. - // - // A segment whose file image is larger than its memory image - // (p_filesz > p_memsz) is malformed and would put `new_file_offset` - // inside the segment's existing bytes. The section header table must - // be part of the relocated tail: the payload is written over its old - // location. let move_src_start = old_rw_file_end; let move_src_end = self.data.len(); + // Rejected: p_filesz > p_memsz (the payload would land inside the + // segment) and a section header table that is not part of the tail. if new_file_offset < move_src_start || old_shdrs.start < move_src_start { return Err(ElfError::InvalidElfFile); } @@ -432,10 +423,8 @@ impl ElfFile { const PHDR_SIZE: usize = size_of::(); const SHDR_SIZE: usize = size_of::(); -/// The file range `offset..offset + len` described by a pair of header fields, -/// or `InvalidElfFile` if it overflows or runs past the end of `data`. Every -/// offset read from a template goes through here before it is used to slice: -/// `--compile-executable-path` accepts arbitrary files. +/// `offset..offset + len` as claimed by a template's headers, or `InvalidElfFile` +/// if it overflows or runs past the end of `data`. fn file_range(data: &[u8], offset: u64, len: u64) -> Result, ElfError> { let start = to_usize(offset)?; let end = start @@ -447,8 +436,7 @@ fn file_range(data: &[u8], offset: u64, len: u64) -> Result, ElfErr Ok(start..end) } -/// An ELF64 offset or size as a slice index. Only fails on a target whose -/// `usize` is narrower than the 64-bit fields. +/// Only fails where `usize` is narrower than the ELF64 fields. fn to_usize(value: u64) -> Result { usize::try_from(value).map_err(|_| ElfError::InvalidElfFile) } @@ -474,8 +462,7 @@ fn read_phdr(data: &[u8], offset: usize) -> Elf64_Phdr { read_struct(&data[offset..][..PHDR_SIZE]) } -/// `table` comes from [`shdr_table`] and `index` is below the `e_shnum` it was -/// computed from. +/// `index` is below the `e_shnum` that `table` (from [`shdr_table`]) was sized by. fn shdr_offset(table: &Range, index: u16) -> usize { table.start + usize::from(index) * SHDR_SIZE } diff --git a/src/parsers/json5.rs b/src/parsers/json5.rs index 92c663393a24..bea7d7fea8af 100644 --- a/src/parsers/json5.rs +++ b/src/parsers/json5.rs @@ -291,8 +291,7 @@ impl<'a> JSON5Parser<'a> { 0 } - /// Records the current position as the start of the token being scanned. - /// `parse_root` rejects documents whose positions do not fit a `Loc`. + /// `parse_root` has rejected any document whose positions do not fit a `Loc`. fn start_token(&mut self) { self.token.loc = usize2loc(self.pos); } @@ -482,8 +481,7 @@ impl<'a> JSON5Parser<'a> { // ── Parser ── fn parse_root(&mut self) -> Result { - // Token and error positions are `i32` `Loc`s, including the EOF token - // at `source.len()`. + // Positions are `i32` `Loc`s, including the EOF token's at `source.len()`. if self.source.len() > i32::MAX as usize { return Err(ParseError::DocumentTooLarge); } diff --git a/src/runtime/image/codecs.rs b/src/runtime/image/codecs.rs index 42e09da28774..e067974ddf2d 100644 --- a/src/runtime/image/codecs.rs +++ b/src/runtime/image/codecs.rs @@ -331,8 +331,7 @@ pub(crate) fn guard(w: u32, h: u32, max_pixels: u64) -> Result<(), Error> { Ok(()) } -/// A width or height reported by a C decoder (`c_int`); anything that is not -/// strictly positive means the header was rejected or is corrupt. +/// A decoder-reported dimension; anything not strictly positive is a corrupt header. #[inline] fn positive_dimension(v: c_int) -> Result { match u32::try_from(v) { @@ -660,10 +659,8 @@ pub(crate) fn modulate(rgba: &mut [u8], brightness: f32, saturation: f32) { unsafe { bun_image_modulate_rgba8(rgba.as_mut_ptr(), rgba.len(), brightness, saturation) } } -/// The highway kernels take `i32` dimensions. The static decoders reject any -/// side over 2³¹−1 (the PNG/BMP format limit; JPEG, WebP and GIF are far -/// smaller) and `do_resize` caps targets at 0x3FFFF, so this only fails for a -/// frame no kernel could address anyway. +/// The highway kernels take `i32` dimensions; every decoder and `do_resize` +/// stay below that, so this is a backstop. #[inline] fn kernel_dimension(v: u32) -> Result { i32::try_from(v).map_err(|_| Error::TooManyPixels) From 154526f87ba385bec7664e68b25f9f15680ea57e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:05:08 +0000 Subject: [PATCH 4/9] Collapse the remaining helper comments to one line --- src/exe_format/elf.rs | 6 ++---- src/runtime/image/codecs.rs | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/exe_format/elf.rs b/src/exe_format/elf.rs index 01a23dc498c9..b60646ff146a 100644 --- a/src/exe_format/elf.rs +++ b/src/exe_format/elf.rs @@ -287,8 +287,7 @@ impl ElfFile { // (zero-initialized statics), corrupting the process. let move_src_start = old_rw_file_end; let move_src_end = self.data.len(); - // Rejected: p_filesz > p_memsz (the payload would land inside the - // segment) and a section header table that is not part of the tail. + // The first means p_filesz > p_memsz; the shdr table has to be in the moved tail. if new_file_offset < move_src_start || old_shdrs.start < move_src_start { return Err(ElfError::InvalidElfFile); } @@ -423,8 +422,7 @@ impl ElfFile { const PHDR_SIZE: usize = size_of::(); const SHDR_SIZE: usize = size_of::(); -/// `offset..offset + len` as claimed by a template's headers, or `InvalidElfFile` -/// if it overflows or runs past the end of `data`. +/// `offset..offset + len` from a template's headers, bounds-checked against `data`. fn file_range(data: &[u8], offset: u64, len: u64) -> Result, ElfError> { let start = to_usize(offset)?; let end = start diff --git a/src/runtime/image/codecs.rs b/src/runtime/image/codecs.rs index e067974ddf2d..8115c1a589fd 100644 --- a/src/runtime/image/codecs.rs +++ b/src/runtime/image/codecs.rs @@ -659,8 +659,7 @@ pub(crate) fn modulate(rgba: &mut [u8], brightness: f32, saturation: f32) { unsafe { bun_image_modulate_rgba8(rgba.as_mut_ptr(), rgba.len(), brightness, saturation) } } -/// The highway kernels take `i32` dimensions; every decoder and `do_resize` -/// stay below that, so this is a backstop. +/// Backstop for the kernels' `i32` dimensions; decoders and `do_resize` stay far below. #[inline] fn kernel_dimension(v: u32) -> Result { i32::try_from(v).map_err(|_| Error::TooManyPixels) From 6e74012913729205906008320da7cb02239fe84e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:30:03 +0000 Subject: [PATCH 5/9] test: share hostLooksNix and readElfInterp through the harness The three patchelf tests and the new template test each carried their own copy of the Nix host check that mirrors host_uses_nix_store_interpreter(); a change to the runtime's check now has one test-side counterpart to update. --- test/bundler/bun-build-compile.test.ts | 49 ++--------------- test/bundler/bundler_compile.test.ts | 74 +++++++------------------- test/harness.ts | 51 ++++++++++++++++++ test/regression/issue/24742.test.ts | 57 +++----------------- test/regression/issue/29290.test.ts | 57 ++------------------ 5 files changed, 84 insertions(+), 204 deletions(-) diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 25f16b630c86..15a759ce828c 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isArm64, isLinux, isMacOS, isMusl, isPosix, isWindows, tempDir } from "harness"; -import { chmodSync, closeSync, cpSync, existsSync, openSync, readSync } from "node:fs"; +import { bunEnv, bunExe, hostLooksNix, isArm64, isLinux, isMacOS, isMusl, isPosix, isWindows, tempDir } from "harness"; +import { chmodSync, cpSync, existsSync } from "node:fs"; import { join } from "path"; describe("Bun.build compile", () => { @@ -476,49 +476,8 @@ if (isLinux) { ? "/lib/ld-musl-x86_64.so.1" : "/lib64/ld-linux-x86-64.so.2"; - // Mirror of `hostUsesNixStoreInterpreter()` in src/exe_format/elf.rs: - // gate out NixOS/Guix hosts where the FHS ldso path is a stub that - // refuses to exec generic binaries. Without this the final - // `Bun.spawn({cmd:[outfile]})` check fails on a NixOS host because - // stub-ld rejects the compiled output, not because the fix is broken. - // Same pattern as the sibling patchelf tests in - // test/regression/issue/29290.test.ts and 24742.test.ts. - function readInterp(buf: Buffer): string | null { - if (buf.length < 64 || buf.readUInt32BE(0) !== 0x7f454c46) return null; - const e_phoff = Number(buf.readBigUInt64LE(32)); - const e_phnum = buf.readUInt16LE(56); - for (let i = 0; i < e_phnum; i++) { - const ph = e_phoff + i * 56; - if (buf.readUInt32LE(ph) !== 3 /* PT_INTERP */) continue; - const p_offset = Number(buf.readBigUInt64LE(ph + 8)); - const p_filesz = Number(buf.readBigUInt64LE(ph + 32)); - const region = buf.subarray(p_offset, p_offset + p_filesz); - const nul = region.indexOf(0); - return region.subarray(0, nul === -1 ? region.length : nul).toString("utf8"); - } - return null; - } - function hostLooksNix(): boolean { - if (existsSync("/etc/NIXOS")) return true; - if (existsSync("/gnu/store")) return true; - try { - // bun is ~1 GB in debug builds; PT_INTERP lives in the first page, - // so read only the leading 4 KiB. - const fd = openSync(bunExe(), "r"); - try { - const buf = Buffer.alloc(4096); - const n = readSync(fd, buf, 0, 4096, 0); - const selfInterp = readInterp(buf.subarray(0, n)); - if (selfInterp && (selfInterp.startsWith("/nix/store/") || selfInterp.startsWith("/gnu/store/"))) { - return true; - } - } finally { - closeSync(fd); - } - } catch {} - return false; - } - + // On a Nix/Guix host the final `Bun.spawn({cmd:[outfile]})` would fail because + // the FHS ldso is a stub there, not because the fix is broken. test.skipIf(!patchelf || !existsSync(ldso) || hostLooksNix())( "compiled binary works when template bun has patchelf-inserted RW PT_LOAD (#31023)", async () => { diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index e2f1836433e9..4e2860db2ec5 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -1,7 +1,7 @@ import { Database } from "bun:sqlite"; import { describe, expect, test } from "bun:test"; -import { closeSync, existsSync, openSync, readSync, rmSync } from "fs"; -import { bunEnv, bunExe, isLinux, isWindows, tempDir } from "harness"; +import { rmSync } from "fs"; +import { bunEnv, bunExe, hostLooksNix, isWindows, readElfInterp, tempDir } from "harness"; import { join } from "path"; import { BundlerTestInput, itBundled as itBundledBase } from "./expectBundled"; @@ -1423,62 +1423,24 @@ function elfTemplate(o: ElfTemplateOptions = {}): Buffer { return buf; } -function elfCString(buf: Buffer, offset: number, size: number): string { - const bytes = buf.subarray(offset, offset + size); - const nul = bytes.indexOf(0); - return bytes.subarray(0, nul === -1 ? bytes.length : nul).toString("latin1"); -} - -/** PT_INTERP of an ELF64 image. The first page of a real binary is enough. */ -function readPtInterp(buf: Buffer): { interp: string; p_filesz: number } | null { - if (buf.length < ELF.EHDR || buf.toString("latin1", 0, 4) !== "\x7fELF") return null; - const phoff = Number(buf.readBigUInt64LE(32)); - for (let i = 0; i < buf.readUInt16LE(56); i++) { - const p = phoff + i * ELF.PHDR; - if (buf.readUInt32LE(p) !== ELF.PT_INTERP) continue; - const p_filesz = Number(buf.readBigUInt64LE(p + 32)); - return { interp: elfCString(buf, Number(buf.readBigUInt64LE(p + 8)), p_filesz), p_filesz }; - } - return null; -} - -/** `sh_size` of the `.interp` section, looked up through the (relocated) section header table. */ -function readInterpSectionSize(buf: Buffer): number | null { - const shoff = Number(buf.readBigUInt64LE(40)); - const shstrtab = shoff + buf.readUInt16LE(62) * ELF.SHDR; - const namesOff = Number(buf.readBigUInt64LE(shstrtab + 24)); - const namesSize = Number(buf.readBigUInt64LE(shstrtab + 32)); - for (let i = 0; i < buf.readUInt16LE(60); i++) { - const s = shoff + i * ELF.SHDR; - const nameOff = buf.readUInt32LE(s); - if (elfCString(buf, namesOff + nameOff, namesSize - nameOff) === ".interp") { - return Number(buf.readBigUInt64LE(s + 32)); +/** `sh_size` of the `.interp` section of a compiled output, through its relocated section header table. */ +function readInterpSectionSize(image: Buffer): number | null { + const shoff = Number(image.readBigUInt64LE(40)); + const shstrtab = shoff + image.readUInt16LE(62) * ELF.SHDR; + const names = image.subarray( + Number(image.readBigUInt64LE(shstrtab + 24)), + Number(image.readBigUInt64LE(shstrtab + 24)) + Number(image.readBigUInt64LE(shstrtab + 32)), + ); + for (let i = 0; i < image.readUInt16LE(60); i++) { + const shdr = shoff + i * ELF.SHDR; + const name = names.subarray(image.readUInt32LE(shdr)); + if (name.subarray(0, name.indexOf(0)).toString("latin1") === ".interp") { + return Number(image.readBigUInt64LE(shdr + 32)); } } return null; } -// Mirror of host_uses_nix_store_interpreter() in src/exe_format/elf.rs: on a Nix/Guix host the -// FHS loader path is a stub, so the rewrite is skipped there (#29290). The runtime also treats -// a bun whose own PT_INTERP is a store path as such a host, so this has to as well, or the -// rewrite test fails on a non-NixOS machine whose bun was installed through Nix. -function hostLooksNix(): boolean { - if (!isLinux) return false; - if (existsSync("/etc/NIXOS") || existsSync("/gnu/store")) return true; - try { - const fd = openSync(bunExe(), "r"); - try { - const head = Buffer.alloc(4096); - const interp = readPtInterp(head.subarray(0, readSync(fd, head, 0, head.length, 0)))?.interp ?? ""; - return interp.startsWith("/nix/store/") || interp.startsWith("/gnu/store/"); - } finally { - closeSync(fd); - } - } catch { - return false; - } -} - async function compileWithElfTemplate(cwd: string, name: string, template: Buffer) { const templatePath = join(cwd, `template-${name}`); await Bun.write(templatePath, template); @@ -1502,7 +1464,7 @@ async function compileWithElfTemplate(cwd: string, name: string, template: Buffe }); const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const output = (await Bun.file(outfile).exists()) ? Buffer.from(await Bun.file(outfile).arrayBuffer()) : null; - return { name, stderr, exitCode, output }; + return { name, stderr, exitCode, outfile, output }; } const NIX_INTERP = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-glibc-2.40-1/lib/ld-linux-x86-64.so.2"; @@ -1556,7 +1518,7 @@ test.skipIf(hostLooksNix())( using dir = tempDir("compile-elf-template-interp", { "entry.js": `console.log("compiled-from-template");`, }); - const { stderr, exitCode, output } = await compileWithElfTemplate( + const { stderr, exitCode, outfile, output } = await compileWithElfTemplate( String(dir), "nix-interp", elfTemplate({ interp: NIX_INTERP }), @@ -1564,7 +1526,7 @@ test.skipIf(hostLooksNix())( expect(stderr).not.toContain("error:"); expect(exitCode).toBe(0); const ldso = "/lib64/ld-linux-x86-64.so.2"; - expect({ ...readPtInterp(output!), sh_size: readInterpSectionSize(output!) }).toEqual({ + expect({ ...readElfInterp(outfile), sh_size: readInterpSectionSize(output!) }).toEqual({ interp: ldso, p_filesz: ldso.length + 1, sh_size: ldso.length + 1, diff --git a/test/harness.ts b/test/harness.ts index b3167919ec84..6992c04b3447 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -2279,3 +2279,54 @@ export const rss: () => number = process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" ? (Bun.unsafe.memoryFootprint as () => number) : process.memoryUsage.rss; + +/** + * `PT_INTERP` of the ELF64 executable at `path`, or `null` if it has none (or + * is not an ELF64 file). Only the first page is read: the bun binary is + * around 1 GB in debug builds, and every real linker puts the program headers + * and the interpreter string there. + */ +export function readElfInterp(path: string): { interp: string; p_filesz: number } | null { + const head = Buffer.alloc(4096); + const fd = openSync(path, "r"); + let n: number; + try { + n = fs.readSync(fd, head, 0, head.length, 0); + } finally { + closeSync(fd); + } + const image = head.subarray(0, n); + if (image.length < 64 || image.toString("latin1", 0, 4) !== "\x7fELF") return null; + const PT_INTERP = 3; + const e_phoff = Number(image.readBigUInt64LE(32)); + const e_phnum = image.readUInt16LE(56); + for (let i = 0; i < e_phnum; i++) { + const phdr = e_phoff + i * 56; + if (phdr + 56 > image.length) return null; + if (image.readUInt32LE(phdr) !== PT_INTERP) continue; + const p_offset = Number(image.readBigUInt64LE(phdr + 8)); + const p_filesz = Number(image.readBigUInt64LE(phdr + 32)); + const bytes = image.subarray(p_offset, p_offset + p_filesz); + const nul = bytes.indexOf(0); + return { interp: bytes.subarray(0, nul === -1 ? bytes.length : nul).toString("latin1"), p_filesz }; + } + return null; +} + +/** + * Mirror of `host_uses_nix_store_interpreter()` in src/exe_format/elf.rs, which + * makes `bun build --compile` keep a store-path `PT_INTERP` (#29290); tests of + * the rewrite skip when this is true. Keep the two in lockstep: like the + * runtime, this also counts a bun whose own interpreter is a store path. + */ +export function hostLooksNix(): boolean { + if (!isLinux) return false; + if (fs.existsSync("/etc/NIXOS") || fs.existsSync("/gnu/store")) return true; + let selfInterp: string | undefined; + try { + selfInterp = readElfInterp(bunExe())?.interp; + } catch { + return false; + } + return selfInterp?.startsWith("/nix/store/") || selfInterp?.startsWith("/gnu/store/") || false; +} diff --git a/test/regression/issue/24742.test.ts b/test/regression/issue/24742.test.ts index 4b8434214d4b..dc21e0ce3572 100644 --- a/test/regression/issue/24742.test.ts +++ b/test/regression/issue/24742.test.ts @@ -6,8 +6,8 @@ // https://github.com/oven-sh/bun/issues/24742 import { expect, test } from "bun:test"; -import { chmodSync, closeSync, cpSync, existsSync, openSync, readSync } from "fs"; -import { bunEnv, bunExe, isLinux, isMusl, tempDir } from "harness"; +import { chmodSync, cpSync, existsSync } from "fs"; +import { bunEnv, bunExe, hostLooksNix, isLinux, isMusl, readElfInterp, tempDir } from "harness"; import { join } from "path"; const patchelf = Bun.which("patchelf"); @@ -24,53 +24,8 @@ const ldso = const ldsoBasename = ldso.split("/").pop()!; const fakeNixInterp = `/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-glibc-2.40-1/lib/${ldsoBasename}`; -// Read PT_INTERP path from an ELF64 LE binary. -function readInterp(buf: Buffer): string | null { - if (buf.length < 64 || buf.readUInt32BE(0) !== 0x7f454c46) return null; - const e_phoff = Number(buf.readBigUInt64LE(32)); - const e_phnum = buf.readUInt16LE(56); - for (let i = 0; i < e_phnum; i++) { - const ph = e_phoff + i * 56; - if (buf.readUInt32LE(ph) !== 3 /* PT_INTERP */) continue; - const p_offset = Number(buf.readBigUInt64LE(ph + 8)); - const p_filesz = Number(buf.readBigUInt64LE(ph + 32)); - const region = buf.subarray(p_offset, p_offset + p_filesz); - const nul = region.indexOf(0); - return region.subarray(0, nul === -1 ? region.length : nul).toString("utf8"); - } - return null; -} - -// Read up to the first 4 KiB of a file (enough for PT_INTERP, which always -// lives in the first ELF page). The bun binary is ~1.3 GB in debug builds, -// so `readFileSync` on it would be wasteful; mirror what the Zig helper does. -function readHead(path: string, bytes = 4096): Buffer { - const fd = openSync(path, "r"); - try { - const buf = Buffer.alloc(bytes); - const n = readSync(fd, buf, 0, bytes, 0); - return buf.subarray(0, n); - } finally { - closeSync(fd); - } -} - -// Mirror of `hostUsesNixStoreInterpreter()` in src/elf.zig. After #29290 the -// normalization is skipped on Nix/Guix hosts — this assertion only holds on -// non-Nix hosts. (The #29290 test covers the NixOS-host branch.) -function hostLooksNix(): boolean { - if (!isLinux) return false; - if (existsSync("/etc/NIXOS")) return true; - if (existsSync("/gnu/store")) return true; - try { - const selfInterp = readInterp(readHead(bunExe())); - if (selfInterp && (selfInterp.startsWith("/nix/store/") || selfInterp.startsWith("/gnu/store/"))) { - return true; - } - } catch {} - return false; -} - +// The normalization is skipped on Nix/Guix hosts (#29290, covered by its own +// test), so this assertion only holds elsewhere. test.skipIf(!isLinux || !patchelf || !existsSync(ldso) || hostLooksNix())( "bun build --compile normalizes /nix/store interpreter (#24742)", async () => { @@ -92,7 +47,7 @@ test.skipIf(!isLinux || !patchelf || !existsSync(ldso) || hostLooksNix())( expect(r.stderr.toString()).toBe(""); expect(r.exitCode).toBe(0); } - expect(readInterp(readHead(fakeNixBun))).toBe(fakeNixInterp); + expect(readElfInterp(fakeNixBun)?.interp).toBe(fakeNixInterp); // Build using the patched binary as the template via --compile-executable-path. // (We run the real bunExe(); only the *source* of the copy is the Nix-patched one.) @@ -121,7 +76,7 @@ test.skipIf(!isLinux || !patchelf || !existsSync(ldso) || hostLooksNix())( // The compiled output's interpreter must be the standard FHS path, // not the /nix/store path baked into fake-nix-bun. - const interp = readInterp(readHead(out)); + const interp = readElfInterp(out)?.interp; expect(interp).toBe(ldso); // And it must actually run on a stock system. diff --git a/test/regression/issue/29290.test.ts b/test/regression/issue/29290.test.ts index ac71e47e37df..2429bfe1f487 100644 --- a/test/regression/issue/29290.test.ts +++ b/test/regression/issue/29290.test.ts @@ -16,8 +16,8 @@ // https://github.com/oven-sh/bun/issues/29290 import { expect, test } from "bun:test"; -import { chmodSync, closeSync, cpSync, existsSync, openSync, readSync } from "fs"; -import { bunEnv, bunExe, isLinux, isMusl, tempDir } from "harness"; +import { chmodSync, cpSync, existsSync } from "fs"; +import { bunEnv, bunExe, hostLooksNix, isLinux, isMusl, readElfInterp, tempDir } from "harness"; import { join } from "path"; const patchelf = Bun.which("patchelf"); @@ -35,53 +35,6 @@ const ldsoBasename = ldso.split("/").pop()!; // Shape of a real /nix/store/ entry: 32-char hash + -. const fakeNixInterp = `/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-glibc-2.40-1/lib/${ldsoBasename}`; -// Read PT_INTERP path from an ELF64 LE binary. -function readInterp(buf: Buffer): string | null { - if (buf.length < 64 || buf.readUInt32BE(0) !== 0x7f454c46) return null; - const e_phoff = Number(buf.readBigUInt64LE(32)); - const e_phnum = buf.readUInt16LE(56); - for (let i = 0; i < e_phnum; i++) { - const ph = e_phoff + i * 56; - if (buf.readUInt32LE(ph) !== 3 /* PT_INTERP */) continue; - const p_offset = Number(buf.readBigUInt64LE(ph + 8)); - const p_filesz = Number(buf.readBigUInt64LE(ph + 32)); - const region = buf.subarray(p_offset, p_offset + p_filesz); - const nul = region.indexOf(0); - return region.subarray(0, nul === -1 ? region.length : nul).toString("utf8"); - } - return null; -} - -// Read up to the first 4 KiB of a file (enough for PT_INTERP, which always -// lives in the first ELF page). The bun binary is ~1.3 GB in debug builds, -// so `readFileSync` on it would be wasteful; mirror what the Zig helper does. -function readHead(path: string, bytes = 4096): Buffer { - const fd = openSync(path, "r"); - try { - const buf = Buffer.alloc(bytes); - const n = readSync(fd, buf, 0, bytes, 0); - return buf.subarray(0, n); - } finally { - closeSync(fd); - } -} - -// Mirror of `hostUsesNixStoreInterpreter()` in src/elf.zig: true iff the -// running bun would skip the FHS rewrite for this host. Test decisions must -// stay in lockstep with the runtime's — if these two drift, tests pass/fail -// for the wrong reason. -function hostLooksNix(): boolean { - if (existsSync("/etc/NIXOS")) return true; - if (existsSync("/gnu/store")) return true; - try { - const selfInterp = readInterp(readHead(bunExe())); - if (selfInterp && (selfInterp.startsWith("/nix/store/") || selfInterp.startsWith("/gnu/store/"))) { - return true; - } - } catch {} - return false; -} - test.skipIf(!isLinux || !patchelf || !existsSync(ldso) || hostLooksNix())( "bun build --compile preserves /nix/store PT_INTERP on NixOS hosts (#29290)", async () => { @@ -104,7 +57,7 @@ test.skipIf(!isLinux || !patchelf || !existsSync(ldso) || hostLooksNix())( expect(r.stderr.toString()).toBe(""); expect(r.exitCode).toBe(0); } - expect(readInterp(readHead(fakeNixBun))).toBe(fakeNixInterp); + expect(readElfInterp(fakeNixBun)?.interp).toBe(fakeNixInterp); // Force the spawned bun's host-detection to say "yes, Nix" without // mutating the shared rootfs. `BUN_DEBUG_FORCE_NIX_HOST=1` is a @@ -134,7 +87,7 @@ test.skipIf(!isLinux || !patchelf || !existsSync(ldso) || hostLooksNix())( // On a NixOS host the output must keep the /nix/store interpreter from // the template — rewriting to FHS would point at a stub-ld that rejects // generic binaries and #29290 reappears. - const interp = readInterp(readHead(out)); + const interp = readElfInterp(out)?.interp; expect(interp).toBe(fakeNixInterp); }, 180_000, @@ -186,7 +139,7 @@ test.skipIf(!isLinux || !patchelf || !existsSync(ldso) || hostLooksNix())( expect(r.exitCode).toBe(0); // Non-NixOS host → normalization kicks in → FHS path. - expect(readInterp(readHead(out))).toBe(ldso); + expect(readElfInterp(out)?.interp).toBe(ldso); // And the binary runs on this (non-NixOS) system. const run = Bun.spawnSync({ cmd: [out], env: bunEnv, stderr: "pipe", stdout: "pipe" }); From cf19105579c64cde4d46cd75f0a5b1f945e18a01 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:18:32 +0000 Subject: [PATCH 6/9] elf: propagate unreadable template headers out of the interpreter rewrite and log them normalize_interpreter swallowed a rejected PT_INTERP or .shstrtab range with a bare return. The rewrite now runs in a function that returns the error and the best-effort wrapper logs it, so mordant's defaulted_failure no longer fires and BUN_DEBUG_elf=1 says why a template was left alone. readElfInterp in the test harness also checks EI_CLASS, as its doc claims. --- src/exe_format/elf.rs | 59 +++++++++++++++++++++++-------------------- test/harness.ts | 3 ++- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/exe_format/elf.rs b/src/exe_format/elf.rs index b60646ff146a..cf1cb41986e6 100644 --- a/src/exe_format/elf.rs +++ b/src/exe_format/elf.rs @@ -63,22 +63,21 @@ impl ElfFile { if host_uses_nix_store_interpreter() { return; } + // Best-effort: a template whose headers cannot be read is compiled as it is. + if let Err(err) = self.rewrite_store_interpreter() { + bun_core::scoped_log!(elf, "leaving PT_INTERP alone: {}", err); + } + } + fn rewrite_store_interpreter(&mut self) -> Result<(), ElfError> { let ehdr = read_ehdr(&self.data); - // Best-effort: a template whose headers do not fit in the file is left alone. - let Ok(phdrs) = phdr_table(&self.data, ehdr) else { - return; - }; - - for phdr_offset in phdrs.step_by(PHDR_SIZE) { + for phdr_offset in phdr_table(&self.data, ehdr)?.step_by(PHDR_SIZE) { let phdr = read_phdr(&self.data, phdr_offset); if phdr.p_type != PT_INTERP { continue; } - let Ok(interp) = file_range(&self.data, phdr.p_offset, phdr.p_filesz) else { - return; - }; + let interp = file_range(&self.data, phdr.p_offset, phdr.p_filesz)?; // reshaped for borrowck — compute replacement under an // immutable borrow, then take a mutable borrow for the writes. @@ -86,11 +85,11 @@ impl ElfFile { let current = slice_to_nul(&self.data[interp.clone()]); if !current.starts_with(b"/nix/store/") && !current.starts_with(b"/gnu/store/") { - return; + return Ok(()); } let Some(last_slash) = strings::last_index_of_char(current, b'/') else { - return; + return Ok(()); }; let basename = ¤t[last_slash + 1..]; @@ -102,13 +101,13 @@ impl ElfFile { } } let Some(replacement) = found else { - return; + return Ok(()); }; // FHS path + NUL must fit in the existing segment (always true for // store paths: 32-char hash + pname + "/lib/" alone exceeds any FHS path). if replacement.len() + 1 > interp.len() { - return; + return Ok(()); } bun_core::scoped_log!( @@ -132,25 +131,30 @@ impl ElfFile { write_u64_le(&mut self.data[phdr_offset + 32..][..8], new_size); write_u64_le(&mut self.data[phdr_offset + 40..][..8], new_size); - self.update_interp_section_size(ehdr, new_size); - return; + // Metadata for readelf only; the rewrite above stands either way. + if let Err(err) = self.update_interp_section_size(ehdr, new_size) { + bun_core::scoped_log!(elf, "leaving the .interp section header alone: {}", err); + } + return Ok(()); } + Ok(()) } - /// Best-effort: keep the `.interp` section header's `sh_size` consistent with - /// the rewritten PT_INTERP so `readelf -S` shows accurate metadata. The kernel - /// only consults PT_INTERP, so any failure here is silently ignored. - fn update_interp_section_size(&mut self, ehdr: Elf64_Ehdr, new_size: u64) { + /// Keeps the `.interp` section header's `sh_size` consistent with the rewritten PT_INTERP. + fn update_interp_section_size( + &mut self, + ehdr: Elf64_Ehdr, + new_size: u64, + ) -> Result<(), ElfError> { + if ehdr.e_shnum == 0 { + return Ok(()); + } if ehdr.e_shstrndx >= ehdr.e_shnum { - return; + return Err(ElfError::InvalidElfFile); } - let Ok(shdrs) = shdr_table(&self.data, ehdr) else { - return; - }; + let shdrs = shdr_table(&self.data, ehdr)?; let strtab_shdr = read_shdr(&self.data, &shdrs, ehdr.e_shstrndx); - let Ok(strtab) = file_range(&self.data, strtab_shdr.sh_offset, strtab_shdr.sh_size) else { - return; - }; + let strtab = file_range(&self.data, strtab_shdr.sh_offset, strtab_shdr.sh_size)?; for i in 0..ehdr.e_shnum { let shdr = read_shdr(&self.data, &shdrs, i); @@ -165,8 +169,9 @@ impl ElfFile { // sh_size @ +32 in Elf64_Shdr let entry = shdr_offset(&shdrs, i); write_u64_le(&mut self.data[entry + 32..][..8], new_size); - return; + return Ok(()); } + Ok(()) } /// Find the `.bun` section and write `payload` so the kernel `mmap`s it at diff --git a/test/harness.ts b/test/harness.ts index 6992c04b3447..116db6468a23 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -2296,7 +2296,8 @@ export function readElfInterp(path: string): { interp: string; p_filesz: number closeSync(fd); } const image = head.subarray(0, n); - if (image.length < 64 || image.toString("latin1", 0, 4) !== "\x7fELF") return null; + const ELFCLASS64 = 2; + if (image.length < 64 || image.toString("latin1", 0, 4) !== "\x7fELF" || image[4] !== ELFCLASS64) return null; const PT_INTERP = 3; const e_phoff = Number(image.readBigUInt64LE(32)); const e_phnum = image.readUInt16LE(56); From dec551bc4372134df5307b287dd186e214326118 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:58:36 +0000 Subject: [PATCH 7/9] source-lints: make the int cast inventory a ceiling and share the file walk A count above its limit fails; a count below it only prints the --update hint. With ~1100 sites and several PRs a week removing some, an exact inventory would turn main red whenever two of them touched the same file, and every removal would need a regeneration; a ceiling needs neither, and mordant-baseline.toml already works this way. The tracked-file walk, symlink dedupe and comment stripping that dead-code-escapes, vm-thread-door and this test each carried now live in rust-sources.ts. Regenerating all three inventories is a no-op. --- .../source-lints/dead-code-escapes.test.ts | 37 +----- .../source-lints/int-cast-expects.test.ts | 105 ++++++++---------- test/internal/source-lints/rust-sources.ts | 51 +++++++++ .../source-lints/vm-thread-door.test.ts | 29 ++--- 4 files changed, 107 insertions(+), 115 deletions(-) create mode 100644 test/internal/source-lints/rust-sources.ts diff --git a/test/internal/source-lints/dead-code-escapes.test.ts b/test/internal/source-lints/dead-code-escapes.test.ts index 0516218a849e..ab640cec26d4 100644 --- a/test/internal/source-lints/dead-code-escapes.test.ts +++ b/test/internal/source-lints/dead-code-escapes.test.ts @@ -18,9 +18,7 @@ // limits the same way so the inventory stays accurate. import { file } from "bun"; -import { realpathSync } from "fs"; -import path from "path"; -import { globAllSources } from "../../../scripts/glob-sources.ts"; +import { sortedInventory, trackedRustSources, withoutLineComments } from "./rust-sources.ts"; // Item-level escapes only: `#[allow(dead_code)]`, combined lists like // `#[allow(dead_code, non_snake_case)]`, and `#[cfg_attr(, allow(dead_code))]` @@ -37,42 +35,17 @@ const ESCAPE = /#\[\s*(?:cfg_attr\([^\]]+?,\s*)?allow\([^)]*\bdead_code\b[^)]*\) const limits: Record = await Bun.file(import.meta.dir + "/dead-code-escape-limits.json").json(); -const root = path.resolve(import.meta.dir, "..", "..", ".."); -const rustSources = globAllSources().rust.filter(p => p.endsWith(".rs")); - -// Only count files tracked in HEAD: editors and `git stash` round-trips can -// leave stray `.rs` files in the working tree (e.g. files a branch deletes -// being temporarily restored), and those must not fail the ratchet. CI runs -// against the committed tree, so every real file is covered. -const tracked: Set | null = (() => { - const r = Bun.spawnSync({ - cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"], - stdout: "pipe", - stderr: "ignore", - }); - if (!r.success) return null; - return new Set(r.stdout.toString().split("\0").filter(Boolean)); -})(); - const counts: Record = {}; -for (const abs of rustSources) { - const source = path.relative(root, abs).replaceAll(path.sep, "/"); - // `src/cli` is a symlink into `src/runtime/cli`; count each file once - // under its canonical path. - if (path.relative(root, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue; - if (tracked !== null && !tracked.has(source)) continue; - const content = await file(abs).text(); - // Whole-file scan so rustfmt-wrapped attributes are counted too; strip - // full-line `//` comments first so commented-out escapes stay ignored. - const stripped = content.replace(/^\s*\/\/.*$/gm, ""); - const n = [...stripped.matchAll(ESCAPE)].length; +for (const { source, abs } of trackedRustSources()) { + // Whole-file scan so rustfmt-wrapped attributes are counted too. + const n = [...withoutLineComments(await file(abs).text()).matchAll(ESCAPE)].length; if (n > 0) counts[source] = n; } if (typeof describe === "undefined") { // Standalone mode (`bun ./test/internal/source-lints/dead-code-escapes.test.ts`): // regenerate the limits file from the current tree. - const sorted = Object.fromEntries(Object.entries(counts).sort(([a], [b]) => (a < b ? -1 : 1))); + const sorted = sortedInventory(counts); await Bun.write(import.meta.dir + "/dead-code-escape-limits.json", JSON.stringify(sorted, null, 2) + "\n"); console.log(`Wrote ${Object.keys(sorted).length} files to dead-code-escape-limits.json`); process.exit(0); diff --git a/test/internal/source-lints/int-cast-expects.test.ts b/test/internal/source-lints/int-cast-expects.test.ts index 2d9e1ffd0e05..071fd246dd10 100644 --- a/test/internal/source-lints/int-cast-expects.test.ts +++ b/test/internal/source-lints/int-cast-expects.test.ts @@ -1,90 +1,73 @@ -// Per-file inventory of `.expect("int cast")` sites in the Rust sources. +// Per-file ceiling on `.expect("int cast")` sites in the Rust sources. // -// `T::try_from(x).expect("int cast")` is the mechanical translation of Zig's -// `@intCast`. Zig only trapped on it in Debug/ReleaseSafe builds; the Rust port -// builds with `panic = "abort"`, so every one of these is a crash in the shipped -// binary for any value that does not fit, and several have turned out to be +// `T::try_from(x).expect("int cast")` is how the port spelled Zig's `@intCast`. +// Zig only trapped on it in Debug/ReleaseSafe builds; the Rust tree builds with +// `panic = "abort"`, so every one of these is a crash in the shipped binary for +// any value that does not fit, and a number of them have turned out to be // reachable from user input (bun:ffi offsets and lengths, --cpu-prof-interval, -// gunzipSync output over 4 GiB, an oversized http2 origin, ...). This test pins -// the count per file so it can only go down. -// -// To remove a site, rewrite it as one of: -// - a checked conversion that returns the function's error (or throws the -// RangeError / validation error its neighbours throw), or -// - a plain conversion (`From`, or `as` for a widening) where the source -// type or a range check right above makes it visibly infallible. -// -// If this fails because a count went UP: rewrite the new site as above rather -// than raising its limit. If it fails because a count went DOWN: you removed -// sites, so lower the limits to match: +// gunzipSync output over 4 GiB, an oversized http2 origin, a corrupt +// --compile-executable-path template, ...). int-cast-expect-limits.json pins +// the count per file: a file may not gain sites, and a file that is not listed +// may not have any. Going below a limit is fine; lower the limits whenever you +// like with // bun ./test/internal/source-lints/int-cast-expects.test.ts --update +// +// A site is cleared by making the conversion honest about where its value +// comes from: +// - a value from outside (a file, the network, a JS argument, a C library): +// a checked conversion that returns the function's error, or throws the +// RangeError / validation error its neighbours throw; +// - a value that a check right above, or its type, already bounds: a plain +// conversion (`From`, or `as` for a widening), so there is nothing left to +// abort on. +// Respelling the abort (`.unwrap()`, another message) or narrowing with a lossy +// `as` is not a clear. This file cannot tell the difference; it counts the one +// canonical spelling so that new sites get noticed in review. import { file } from "bun"; import { describe, test } from "bun:test"; -import { realpathSync } from "fs"; import path from "path"; -import { globAllSources } from "../../../scripts/glob-sources.ts"; +import { sortedInventory, trackedRustSources, withoutLineComments } from "./rust-sources.ts"; const SITE = /\.expect\(\s*"int cast"\s*\)/g; - -const root = path.resolve(import.meta.dir, "..", "..", ".."); -const LIMITS = import.meta.dir + "/int-cast-expect-limits.json"; +const LIMITS = path.join(import.meta.dir, "int-cast-expect-limits.json"); const UPDATE = "bun ./test/internal/source-lints/int-cast-expects.test.ts --update"; -// Only count files tracked in HEAD: editors and `git stash` round-trips can -// leave stray `.rs` files in the working tree, and those must not fail the -// ratchet. CI runs against the committed tree, so every real file is covered. -const tracked: Set | null = (() => { - const r = Bun.spawnSync({ - cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"], - stdout: "pipe", - stderr: "ignore", - }); - if (!r.success) return null; - return new Set(r.stdout.toString().split("\0").filter(Boolean)); -})(); - const counts: Record = {}; -for (const abs of globAllSources().rust.filter(p => p.endsWith(".rs"))) { - const source = path.relative(root, abs).replaceAll(path.sep, "/"); - // `src/cli` is a symlink into `src/runtime/cli`; count each file once - // under its canonical path. - if (path.relative(root, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue; - if (tracked !== null && !tracked.has(source)) continue; - const content = await file(abs).text(); - if (!content.includes("int cast")) continue; - // Whole-file scan so a call rustfmt wrapped onto its own line still counts; - // full-line `//` comments are stripped so a commented-out site does not. - const stripped = content.replace(/^\s*\/\/.*$/gm, ""); - const n = [...stripped.matchAll(SITE)].length; +for (const { source, abs } of trackedRustSources()) { + const text = await file(abs).text(); + if (!text.includes("int cast")) continue; + // Whole-file scan so a call rustfmt wrapped onto its own line still counts. + const n = [...withoutLineComments(text).matchAll(SITE)].length; if (n > 0) counts[source] = n; } if (process.argv.includes("--update")) { - const sorted = Object.fromEntries(Object.entries(counts).sort(([a], [b]) => (a < b ? -1 : 1))); - await Bun.write(LIMITS, JSON.stringify(sorted, null, 2) + "\n"); - const total = Object.values(sorted).reduce((a, b) => a + b, 0); - console.log(`Wrote ${Object.keys(sorted).length} files (${total} sites) to ${path.basename(LIMITS)}`); + const inventory = sortedInventory(counts); + await Bun.write(LIMITS, JSON.stringify(inventory, null, 2) + "\n"); + const total = Object.values(inventory).reduce((a, b) => a + b, 0); + console.log(`Wrote ${Object.keys(inventory).length} files (${total} sites) to ${path.basename(LIMITS)}`); process.exit(0); } -const limits: Record = await Bun.file(LIMITS).json(); +const limits: Record = await file(LIMITS).json(); +const files = [...new Set([...Object.keys(limits), ...Object.keys(counts)])].sort(); + +const belowLimit = files.filter(source => (counts[source] ?? 0) < (limits[source] ?? 0)); +if (belowLimit.length > 0) { + console.log(`${belowLimit.length} file(s) are below their int cast limit; lower the limits with: ${UPDATE}`); +} describe('.expect("int cast") sites', () => { - const files = [...new Set([...Object.keys(limits), ...Object.keys(counts)])].sort(); test.each(files)("%s", source => { const limit = limits[source] ?? 0; const count = counts[source] ?? 0; if (count > limit) { throw new Error( - `${source} has ${count} .expect("int cast") sites, up from ${limit}. Each one aborts the process on a value ` + - `that does not fit. Return an error from the failed conversion instead, or use a plain conversion where the ` + - `value is already range-checked (see the header of int-cast-expects.test.ts).`, - ); - } - if (count < limit) { - throw new Error( - `${source} has ${count} .expect("int cast") sites, down from ${limit}. Lower the limit so they cannot come back: ${UPDATE}`, + `${source} has ${count} .expect("int cast") sites; its limit is ${limit}. Each one aborts the process on a ` + + `value that does not fit: return an error from the failed conversion, or use a plain conversion where the ` + + `value is already bounded (see the header of int-cast-expects.test.ts). If a site really is unavoidable, ` + + `raise the limit with \`${UPDATE}\` and say why in the PR.`, ); } }); diff --git a/test/internal/source-lints/rust-sources.ts b/test/internal/source-lints/rust-sources.ts new file mode 100644 index 000000000000..6eee88aa1569 --- /dev/null +++ b/test/internal/source-lints/rust-sources.ts @@ -0,0 +1,51 @@ +// Shared by the per-file inventories in this directory (dead-code-escapes, +// vm-thread-door, int-cast-expects): which Rust files to scan, and how to +// ignore commented-out code. + +import { realpathSync } from "fs"; +import path from "path"; +import { globAllSources } from "../../../scripts/glob-sources.ts"; + +export const repoRoot = path.resolve(import.meta.dir, "..", "..", ".."); + +// Only files tracked in HEAD: editors and `git stash` round-trips can leave +// stray `.rs` files in the working tree (e.g. files a branch deletes being +// temporarily restored), and those must not fail an inventory. CI runs against +// the committed tree, so every real file is covered. +function trackedFiles(): Set | null { + const r = Bun.spawnSync({ + cmd: ["git", "-C", repoRoot, "ls-tree", "-r", "--name-only", "-z", "HEAD"], + stdout: "pipe", + stderr: "ignore", + }); + if (!r.success) return null; + return new Set(r.stdout.toString().split("\0").filter(Boolean)); +} + +/** + * Every tracked Rust source file, as its repo-relative path (the inventory + * key) and absolute path, each file once: `src/cli` is a symlink into + * `src/runtime/cli`, so only the canonical path is reported. + */ +export function trackedRustSources(): { source: string; abs: string }[] { + const tracked = trackedFiles(); + const out: { source: string; abs: string }[] = []; + for (const abs of globAllSources().rust) { + if (!abs.endsWith(".rs")) continue; + const source = path.relative(repoRoot, abs).replaceAll(path.sep, "/"); + if (path.relative(repoRoot, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue; + if (tracked !== null && !tracked.has(source)) continue; + out.push({ source, abs }); + } + return out; +} + +/** The file with its full-line `//` comments removed, so commented-out code is not counted. */ +export function withoutLineComments(text: string): string { + return text.replace(/^\s*\/\/.*$/gm, ""); +} + +/** `counts` as a sorted JSON object, the on-disk shape of every limits file here. */ +export function sortedInventory(counts: Record): Record { + return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); +} diff --git a/test/internal/source-lints/vm-thread-door.test.ts b/test/internal/source-lints/vm-thread-door.test.ts index 6bd0ac3428c8..8a95b8ef775e 100644 --- a/test/internal/source-lints/vm-thread-door.test.ts +++ b/test/internal/source-lints/vm-thread-door.test.ts @@ -29,11 +29,9 @@ import { file } from "bun"; import { describe, expect, test } from "bun:test"; -import { realpathSync } from "fs"; import path from "path"; -import { globAllSources } from "../../../scripts/glob-sources.ts"; +import { repoRoot, sortedInventory, trackedRustSources, withoutLineComments } from "./rust-sources.ts"; -const root = path.resolve(import.meta.dir, "..", "..", ".."); const INVENTORY = import.meta.dir + "/vm-thread-door.inventory.json"; const SCOPED = ["src/jsc/", "src/runtime/", "src/event_loop/", "src/sql_jsc/", "src/http_jsc/"]; @@ -52,25 +50,12 @@ const PATTERNS: [name: string, re: RegExp][] = [ ["uv_queue_work", /(? | null = (() => { - const r = Bun.spawnSync({ - cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"], - stdout: "pipe", - stderr: "ignore", - }); - if (!r.success) return null; - return new Set(r.stdout.toString().split("\0").filter(Boolean)); -})(); - type Inventory = Record>; const found: Inventory = {}; -for (const abs of globAllSources().rust.filter(p => p.endsWith(".rs"))) { - const source = path.relative(root, abs).replaceAll(path.sep, "/"); - if (path.relative(root, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue; +for (const { source, abs } of trackedRustSources()) { if (!SCOPED.some(p => source.startsWith(p)) || DOOR.has(source)) continue; - if (tracked !== null && !tracked.has(source)) continue; - const stripped = (await file(abs).text()).replace(/^\s*\/\/.*$/gm, ""); + const stripped = withoutLineComments(await file(abs).text()); for (const [name, re] of PATTERNS) { const matches = [...stripped.matchAll(re)]; if (matches.length === 0) continue; @@ -83,9 +68,9 @@ for (const abs of globAllSources().rust.filter(p => p.endsWith(".rs"))) { } } -const sortKeys = (o: Record): Record => - Object.fromEntries(Object.entries(o).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); -const normalized: Inventory = sortKeys(Object.fromEntries(Object.entries(found).map(([k, v]) => [k, sortKeys(v)]))); +const normalized: Inventory = sortedInventory( + Object.fromEntries(Object.entries(found).map(([k, v]) => [k, sortedInventory(v)])), +); if (process.argv.includes("--update")) { await Bun.write(INVENTORY, JSON.stringify(normalized, null, 2) + "\n"); @@ -113,7 +98,7 @@ describe("VM thread door", () => { }); test("VirtualMachine stays !Send + !Sync", async () => { - const vm = await file(path.join(root, "src/jsc/VirtualMachine.rs")).text(); + const vm = await file(path.join(repoRoot, "src/jsc/VirtualMachine.rs")).text(); expect(vm).not.toMatch(/unsafe\s+impl\s+(?:Send|Sync)\s+for\s+VirtualMachine\b/); expect(vm).toContain(">::some_item"); }); From 62e21f3363a49d4add1f1058c347866d92a32596 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:58:37 +0000 Subject: [PATCH 8/9] image: store the last size as Option<(u32, u32)>; macho: keep the __bun offset as the u32 it was read as Image.width/.height kept the decoded u32 dimensions in two i32 cells with -1 meaning "no pipeline has run yet", which is what the six remaining casts in Image.rs were for. rotate() keeps its degrees as the u16 the pipeline stores and the input size check converts st_size with a fallback instead of an abort, so Image.rs leaves the limits file. macho.rs converted the __bun section's u32 offset to u64 and back. --- src/exe_format/macho.rs | 2 +- src/runtime/image/Image.rs | 29 ++++++++----------- src/runtime/image/codecs.rs | 4 +-- .../source-lints/int-cast-expect-limits.json | 3 +- test/js/bun/image/image.test.ts | 5 ++-- 5 files changed, 18 insertions(+), 25 deletions(-) diff --git a/src/exe_format/macho.rs b/src/exe_format/macho.rs index 1aa12bdf5bed..417d9276efd7 100644 --- a/src/exe_format/macho.rs +++ b/src/exe_format/macho.rs @@ -161,7 +161,7 @@ impl MachoFile { segname: SEGNAME_BUN, addr: original_vmaddr, size: total_size, - offset: u32::try_from(original_fileoff).expect("int cast"), + offset: sect.offset, align: (blob_alignment as f64).log2() as u32, reloff: 0, nreloc: 0, diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 182bd7d183b9..013aa3faed01 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -67,10 +67,9 @@ pub struct Image { /// Apply EXIF Orientation (JPEG) before any user ops, the way Sharp's /// `.rotate()`-with-no-args / `autoOrient` does. auto_orient: bool, - /// Populated after a pipeline has run once; lets `.width`/`.height` answer - /// synchronously after the first await. - last_width: Cell, - last_height: Cell, + /// `(width, height)` once a pipeline has run; lets `.width`/`.height` + /// answer synchronously after the first await (-1 before that). + last_size: Cell>, /// Strong while at least one PipelineTask is in flight, weak otherwise. The /// Strong→wrapper→sourceJS-slot chain is what keeps the borrowed ArrayBuffer /// alive across the WorkPool roundtrip; switching to weak when idle lets GC @@ -86,8 +85,7 @@ impl Default for Image { pipeline: Cell::new(Pipeline::default()), max_pixels: codecs::DEFAULT_MAX_PIXELS, auto_orient: true, - last_width: Cell::new(-1), - last_height: Cell::new(-1), + last_size: Cell::new(None), this_ref: JsCell::new(JsRef::empty()), pending_tasks: Cell::new(0), } @@ -501,13 +499,13 @@ impl Image { // coerce_int for the same NaN/Inf/huge-finite reasons as everywhere else; // ±1e15 is plenty of headroom for "any multiple of 90 a user might pass". let raw: i64 = coerce_int!(i64, args[0].as_number(), -1e15, 1e15); - let deg: u32 = u32::try_from(raw.rem_euclid(360)).unwrap(); + let deg = raw.rem_euclid(360) as u16; if deg != 0 && deg != 90 && deg != 180 && deg != 270 { return Err(global.throw_invalid_arguments(format_args!( "rotate: only multiples of 90 are supported" ))); } - self.update_pipeline(|p| p.rotate = u16::try_from(deg).expect("int cast")); + self.update_pipeline(|p| p.rotate = deg); Ok(callframe.this()) } @@ -916,12 +914,12 @@ impl Image { impl Image { #[bun_jsc::host_fn(getter)] pub(crate) fn get_width(&self, _: &JSGlobalObject) -> JSValue { - JSValue::js_number(f64::from(self.last_width.get())) + JSValue::js_number(self.last_size.get().map_or(-1.0, |(w, _)| f64::from(w))) } #[bun_jsc::host_fn(getter)] pub(crate) fn get_height(&self, _: &JSGlobalObject) -> JSValue { - JSValue::js_number(f64::from(self.last_height.get())) + JSValue::js_number(self.last_size.get().map_or(-1.0, |(_, h)| f64::from(h))) } } @@ -948,8 +946,7 @@ impl Image { mem::swap(&mut w, &mut h); } } - self.last_width.set(i32::try_from(w).expect("int cast")); - self.last_height.set(i32::try_from(h).expect("int cast")); + self.last_size.set(Some((w, h))); let obj = JSValue::create_empty_object(global, 3); obj.put(global, b"width", JSValue::js_number(f64::from(w))); obj.put(global, b"height", JSValue::js_number(f64::from(h))); @@ -1226,8 +1223,7 @@ impl Image { ); match result { TaskResult::Encoded { out, format, w, h } => { - self.last_width.set(i32::try_from(w).expect("int cast")); - self.last_height.set(i32::try_from(h).expect("int cast")); + self.last_size.set(Some((w, h))); Ok((out, format.mime())) } TaskResult::Err(e) => Err(global.throw(format_args!( @@ -1589,7 +1585,7 @@ impl PipelineTask { }); return; } - if u64::try_from(st.st_size.max(0)).expect("int cast") > MAX_INPUT_FILE_BYTES { + if u64::try_from(st.st_size).unwrap_or(0) > MAX_INPUT_FILE_BYTES { self.result = TaskResult::Err(codecs::Error::TooManyPixels); return; } @@ -1763,8 +1759,7 @@ impl PipelineTask { // so writing `image.*` there would race the synchronous getters. match &self.result { TaskResult::Encoded { w, h, .. } | TaskResult::Meta { w, h, .. } => { - image.last_width.set(i32::try_from(*w).expect("int cast")); - image.last_height.set(i32::try_from(*h).expect("int cast")); + image.last_size.set(Some((*w, *h))); } _ => {} } diff --git a/src/runtime/image/codecs.rs b/src/runtime/image/codecs.rs index 8115c1a589fd..4df2016c421a 100644 --- a/src/runtime/image/codecs.rs +++ b/src/runtime/image/codecs.rs @@ -418,9 +418,7 @@ pub(crate) fn probe(bytes: &[u8], max_pixels: u64) -> Result { } } // The PNG/JPEG/BMP specs all cap each dimension at 2³¹−1; a header with - // a larger u32 value is corrupt regardless of `maxPixels`. Reject here so - // the i32 `last_width`/`last_height` casts downstream can't trap on a - // 24-byte hostile IHDR. + // a larger u32 value is corrupt regardless of `maxPixels`. if w == 0 || h == 0 || w > i32::MAX as u32 || h > i32::MAX as u32 { return Err(Error::DecodeFailed); } diff --git a/test/internal/source-lints/int-cast-expect-limits.json b/test/internal/source-lints/int-cast-expect-limits.json index 035c19f2e53b..1beee897dad5 100644 --- a/test/internal/source-lints/int-cast-expect-limits.json +++ b/test/internal/source-lints/int-cast-expect-limits.json @@ -38,7 +38,7 @@ "src/css/selectors/builder.rs": 1, "src/css/selectors/parser.rs": 1, "src/css_jsc/color_js.rs": 13, - "src/exe_format/macho.rs": 6, + "src/exe_format/macho.rs": 5, "src/exe_format/pe.rs": 3, "src/glob/GlobWalker.rs": 13, "src/glob/matcher.rs": 1, @@ -162,7 +162,6 @@ "src/runtime/crypto/PBKDF2.rs": 1, "src/runtime/ffi/FFIObject.rs": 2, "src/runtime/ffi/ffi_body.rs": 3, - "src/runtime/image/Image.rs": 8, "src/runtime/image/backend_wic.rs": 9, "src/runtime/image/codec_bmp.rs": 1, "src/runtime/image/codec_jpeg.rs": 8, diff --git a/test/js/bun/image/image.test.ts b/test/js/bun/image/image.test.ts index b29502b1e1b4..ef7416a4f30b 100644 --- a/test/js/bun/image/image.test.ts +++ b/test/js/bun/image/image.test.ts @@ -236,12 +236,13 @@ describe("Bun.Image", () => { test("metadata() reads PNG dimensions", async () => { const img = new Bun.Image(cornersPng); + // `.width`/`.height` are -1 until the first awaited terminal fills them in. + expect([img.width, img.height]).toEqual([-1, -1]); const meta = await img.metadata(); expect(meta.width).toBe(4); expect(meta.height).toBe(3); expect(meta.format).toBe("png"); - expect(img.width).toBe(4); - expect(img.height).toBe(3); + expect([img.width, img.height]).toEqual([4, 3]); }); test("PNG → PNG round-trip preserves every pixel", async () => { From c56508cba6bfcb8ed3c1925b53ab26b8c3a2ce23 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:00:50 +0000 Subject: [PATCH 9/9] image: one-line doc comment on last_size --- src/runtime/image/Image.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 013aa3faed01..d6dc8318a38c 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -67,8 +67,7 @@ pub struct Image { /// Apply EXIF Orientation (JPEG) before any user ops, the way Sharp's /// `.rotate()`-with-no-args / `autoOrient` does. auto_orient: bool, - /// `(width, height)` once a pipeline has run; lets `.width`/`.height` - /// answer synchronously after the first await (-1 before that). + /// Set by the first awaited terminal; `.width`/`.height` answer -1 until then. last_size: Cell>, /// Strong while at least one PipelineTask is in flight, weak otherwise. The /// Strong→wrapper→sourceJS-slot chain is what keeps the borrowed ArrayBuffer