From b08eb4b8a0fb7faee2a08d10af91f844caaf4d1b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:57:06 +0000 Subject: [PATCH] compile: exit with an error instead of SIGBUS when a standalone executable is truncated Linux maps each PT_LOAD's full p_filesz at execve without checking that the file is that long, so a compiled executable whose download or copy was cut short still starts, and the first read of the embedded module graph (the trailer, in StandaloneModuleGraph::from_executable) dies with SIGBUS and a crash report. Before reading the payload's length header and its trailer, probe the page each one lives on with madvise(MADV_POPULATE_READ). It faults the page in exactly like the read that follows would, but a page the file does not back comes back as EFAULT, which is turned into a real error message and exit code 1. The executable is never opened or stat'd, and kernels without MADV_POPULATE_READ (< 5.14) return EINVAL and keep the previous behaviour. Because truncation removes a suffix of the file, the trailer page being present implies everything before it is too. macOS, Windows and FreeBSD reject a truncated image at exec time, so the probes are Linux/Android only. --- src/standalone_graph/StandaloneModuleGraph.rs | 85 ++++++++++- test/bundler/bun-build-compile.test.ts | 143 +++++++++++++++++- 2 files changed, 220 insertions(+), 8 deletions(-) diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index f3e4e058152f..1f6d211d26a8 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -432,6 +432,39 @@ mod elf { /// `&[u8]` here would freeze it to read-only and make the later /// `from_bytes` writable subslices UB under Stacked Borrows. pub(super) fn get_data() -> Option<(*mut u8, usize)> { + get_payload(get_blob()?) + } + + /// `get_data()` for process startup. Linux's ELF loader maps each + /// `PT_LOAD`'s full `p_filesz` without checking that the file is really + /// that long, so a truncated executable (interrupted download or copy) + /// still execs and the first read of a page past EOF, in + /// `from_executable`, is a SIGBUS. Probe the two pages `from_executable` + /// reads first and exit with a real error instead. + #[cfg(any(target_os = "linux", target_os = "android"))] + pub(super) fn get_data_or_exit_if_truncated() -> Option<(*mut u8, usize)> { + let blob = get_blob()?; + exit_if_page_is_past_eof(blob as usize); + let (base, len) = get_payload(blob)?; + // Truncation removes a suffix of the file. The trailer `from_executable` + // reads next is the last thing in the payload, so if its page is on disk, + // so is everything `from_bytes` and the runtime read after it. `len` is + // unvalidated file data, hence `wrapping_add`. + exit_if_page_is_past_eof((base as usize).wrapping_add(len - 1)); + Some((base, len)) + } + + /// FreeBSD's ELF loader refuses a truncated executable at `execve`, so + /// there is nothing to probe. + #[cfg(target_os = "freebsd")] + pub(super) fn get_data_or_exit_if_truncated() -> Option<(*mut u8, usize)> { + get_data() + } + + /// Address of the `[u64 payload_len][payload]` blob that + /// `bun_exe_format::elf::write_bun_section` appended, or `None` in a plain + /// `bun` binary. + fn get_blob() -> Option<*mut u8> { // SAFETY: FFI call. let vaddr_ptr = unsafe { Bun__getStandaloneModuleGraphELFVaddr() }; if vaddr_ptr.is_null() { @@ -444,18 +477,56 @@ mod elf { } // BUN_COMPILED.size holds the virtual address of the appended data. // The kernel mapped it via PT_LOAD, so we can dereference directly. - // Format at target: [u64 payload_len][payload bytes] // Synthesize a `*mut u8` directly so the provenance carries write // permission for the in-place bytecode mutation done by JSC. - let target = vaddr as *mut u8; - // SAFETY: target points to 8-byte little-endian length prefix. + Some(vaddr as *mut u8) + } + + fn get_payload(blob: *mut u8) -> Option<(*mut u8, usize)> { + // SAFETY: blob points to 8-byte little-endian length prefix. let payload_len = - u64::from_le_bytes(unsafe { core::ptr::read_unaligned(target.cast::<[u8; 8]>()) }); + u64::from_le_bytes(unsafe { core::ptr::read_unaligned(blob.cast::<[u8; 8]>()) }); if payload_len < 8 { return None; } - // SAFETY: payload_len bytes follow the 8-byte header at `target`. - Some((unsafe { target.add(8) }, payload_len as usize)) + // SAFETY: payload_len bytes follow the 8-byte header at `blob`. + Some((unsafe { blob.add(8) }, payload_len as usize)) + } + + /// `MADV_POPULATE_READ` faults the page in exactly like the read that + /// follows it would, except that a page the file does not back reports + /// `EFAULT` instead of raising SIGBUS. It never opens or stats the + /// executable. Kernels older than 5.14 reject the advice with `EINVAL`; + /// that and every other outcome fall through to the unchecked read. + #[cfg(any(target_os = "linux", target_os = "android"))] + fn exit_if_page_is_past_eof(addr: usize) { + let page_size = bun_alloc::page_size(); + let page = (addr & !(page_size - 1)) as *mut core::ffi::c_void; + // SAFETY: madvise validates the range itself and only touches page + // tables, never the bytes. + let rc = unsafe { libc::madvise(page, page_size, libc::MADV_POPULATE_READ) }; + if rc != 0 && bun_sys::last_errno() == libc::EFAULT { + exit_truncated(); + } + } + + #[cfg(any(target_os = "linux", target_os = "android"))] + #[cold] + #[inline(never)] + fn exit_truncated() -> ! { + match bun_core::self_exe_path() { + Ok(exe) => bun_core::err_generic!( + "{} is incomplete: the file ends before the program embedded in it does.", + bun_core::fmt::quote(exe.as_bytes()) + ), + Err(_) => bun_core::err_generic!( + "This executable is incomplete: the file ends before the program embedded in it does." + ), + } + bun_core::note!( + "It was probably truncated while being downloaded or copied. Re-download or reinstall it and try again." + ); + bun_core::Global::exit(1); } } @@ -2151,7 +2222,7 @@ impl StandaloneModuleGraph { #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] { - let Some((base, len)) = elf::get_data() else { + let Some((base, len)) = elf::get_data_or_exit_if_truncated() else { return Ok(None); }; if len < size_of::() + TRAILER.len() { diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 3020baa43de3..f8db7f6ddd45 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -1,6 +1,17 @@ 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 { + chmodSync, + closeSync, + copyFileSync, + cpSync, + existsSync, + openSync, + readSync, + realpathSync, + truncateSync, +} from "node:fs"; +import { release } from "node:os"; import { join } from "path"; describe("Bun.build compile", () => { @@ -175,6 +186,52 @@ describe("compiled binary validity", () => { }); }); +/** + * File offset and size of the `.bun` section (`[u64 payload_len][payload]`) of a + * compiled ELF executable, read from the headers only: a debug+ASAN build is + * ~1 GB, so the whole file is never loaded into memory. + */ +function readBunSection(path: string): { offset: number; size: number } { + const fd = openSync(path, "r"); + try { + const pread = (offset: number, length: number) => { + const buf = Buffer.alloc(length); + expect(readSync(fd, buf, 0, length, offset)).toBe(length); + return buf; + }; + + // Elf64_Ehdr: e_shoff @ 40 (u64), e_shentsize @ 58, e_shnum @ 60, e_shstrndx @ 62 (u16) + const ehdr = pread(0, 64); + const shoff = Number(ehdr.readBigUInt64LE(40)); + const shentsize = ehdr.readUInt16LE(58); + const shnum = ehdr.readUInt16LE(60); + const shstrndx = ehdr.readUInt16LE(62); + const shdrs = pread(shoff, shnum * shentsize); + + // Elf64_Shdr: sh_name @ 0 (u32), sh_offset @ 24 (u64), sh_size @ 32 (u64) + const shdr = (i: number) => { + const at = i * shentsize; + return { + name: shdrs.readUInt32LE(at), + offset: Number(shdrs.readBigUInt64LE(at + 24)), + size: Number(shdrs.readBigUInt64LE(at + 32)), + }; + }; + const shstrtab = shdr(shstrndx); + const names = pread(shstrtab.offset, shstrtab.size); + + for (let i = 0; i < shnum; i++) { + const { name, offset, size } = shdr(i); + if (names.toString("latin1", name, names.indexOf(0, name)) === ".bun") { + return { offset, size }; + } + } + throw new Error(`${path} has no .bun section`); + } finally { + closeSync(fd); + } +} + if (isLinux) { describe("ELF section", () => { test("compiled binary runs with execute-only permissions", async () => { @@ -326,6 +383,90 @@ if (isLinux) { expect(foundBunSection).toBe(true); }); + // The kernel maps the whole PT_LOAD holding the payload without checking + // that the file is actually that long, so a truncated executable + // (interrupted download or copy) still execs and used to die with SIGBUS + // on the first read of the payload, in StandaloneModuleGraph::from_executable. + // The loader now probes the pages it is about to read with + // MADV_POPULATE_READ (Linux 5.14+) and exits with an error instead. + // + // The cut points come from the `.bun` section header, which + // `write_bun_section` points at the relocated payload: under debug+ASAN + // hundreds of MB of debug info follow the payload in the file, so cutting + // a fixed distance from the end would not reach it. + // + // Higher per-test timeout for the same reason as the #29963 test below: + // compiling copies the entire bun binary (and this test copies it twice more). + const [kernelMajor, kernelMinor] = release().split(".").map(Number); + const hasMadvPopulateRead = kernelMajor > 5 || (kernelMajor === 5 && kernelMinor >= 14); + test.skipIf(!hasMadvPopulateRead)( + "truncated compiled binary exits with an error instead of SIGBUS", + async () => { + // Cutting `keep` bytes into a payload of more than `2 * keep` bytes leaves + // the page holding the length header (first probe) on disk and puts the + // page holding the trailer (second probe) entirely past EOF, for any + // page size up to 64 KiB. + const keep = 64 * 1024; + using dir = tempDir("build-compile-truncated", { + "big.txt": Buffer.alloc(1024 * 1024, "x").toString(), + "app.js": `import big from "./big.txt" with { type: "text" }; console.log("intact-" + big.length);`, + }); + + const exe = join(String(dir), "app"); + const result = await Bun.build({ + entrypoints: [join(String(dir), "app.js")], + compile: { outfile: exe }, + }); + expect(result.success).toBe(true); + + const run = async (path: string) => { + await using proc = Bun.spawn({ + cmd: [path], + env: { + ...bunEnv, + // detect_leaks=0: LSAN's exit-time scan of a cut binary reads the + // same unbacked payload pages and dies of the SIGBUS itself. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), + }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }; + // A download of `exe` that stopped after `length` bytes. Always a fresh + // copy: a file that was just executed stays ETXTBSY for a moment. + const cutAt = (name: string, length: number) => { + const copy = join(String(dir), name); + copyFileSync(exe, copy); + truncateSync(copy, length); + return copy; + }; + const incomplete = (path: string) => ({ + stdout: "", + stderr: + // The loader names the file via /proc/self/exe, hence realpath. + `error: "${realpathSync(path)}" is incomplete: the file ends before the program embedded in it does.\n` + + "note: It was probably truncated while being downloaded or copied. Re-download or reinstall it and try again.\n", + exitCode: 1, + }); + + const { offset: payloadOffset, size: payloadSize } = readBunSection(exe); + expect(payloadSize).toBeGreaterThan(2 * keep); + expect(await run(exe)).toEqual({ stdout: "intact-1048576\n", stderr: "", exitCode: 0 }); + + // Most of the payload made it, but not the trailer at its end. + const missingTrailer = cutAt("app-missing-trailer", payloadOffset + keep); + expect(await run(missingTrailer)).toEqual(incomplete(missingTrailer)); + + // None of the payload made it, not even its length header. + const missingPayload = cutAt("app-missing-payload", payloadOffset); + expect(await run(missingPayload)).toEqual(incomplete(missingPayload)); + }, + 60_000, + ); + // Regression guard for #29963. WSL1's kernel ELF loader rejects `execve` // with ENOEXEC when it sees a late PT_LOAD produced by repurposing // PT_GNU_STACK. The compiled binary must instead: