Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 78 additions & 7 deletions src/standalone_graph/StandaloneModuleGraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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::<Offsets>() + TRAILER.len() {
Expand Down
143 changes: 142 additions & 1 deletion test/bundler/bun-build-compile.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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:
Expand Down
Loading