770 lines
30 KiB
TypeScript
770 lines
30 KiB
TypeScript
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 { join } from "path";
|
|
|
|
describe("Bun.build compile", () => {
|
|
test("compile with current platform target string", async () => {
|
|
using dir = tempDir("build-compile-target", {
|
|
"app.js": `console.log("Cross-compiled app");`,
|
|
});
|
|
|
|
const os = isMacOS ? "darwin" : isLinux ? "linux" : isWindows ? "windows" : "unknown";
|
|
const arch = isArm64 ? "aarch64" : "x64";
|
|
const musl = isMusl ? "-musl" : "";
|
|
const target = `bun-${os}-${arch}${musl}` as any;
|
|
const outdir = join(dir + "", "out");
|
|
|
|
const result = await Bun.build({
|
|
entrypoints: [join(dir + "", "app.js")],
|
|
outdir,
|
|
compile: {
|
|
target: target,
|
|
outfile: "app-cross",
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.outputs.length).toBe(1);
|
|
expect(result.outputs[0].path).toEndWith(isWindows ? "app-cross.exe" : "app-cross");
|
|
|
|
const exists = await Bun.file(result.outputs[0].path).exists();
|
|
|
|
// Verify that we do write it to the outdir.
|
|
expect(result.outputs[0].path.replaceAll("\\", "/")).toStartWith(outdir.replaceAll("\\", "/"));
|
|
expect(exists).toBe(true);
|
|
});
|
|
|
|
test("compile with invalid target fails gracefully", async () => {
|
|
using dir = tempDir("build-compile-invalid", {
|
|
"index.js": `console.log("test");`,
|
|
});
|
|
|
|
expect(() =>
|
|
Bun.build({
|
|
entrypoints: [join(dir, "index.js")],
|
|
compile: {
|
|
target: "bun-invalid-platform",
|
|
outfile: join(dir, "invalid-app"),
|
|
},
|
|
}),
|
|
).toThrowErrorMatchingInlineSnapshot(`"Unknown compile target: bun-invalid-platform"`);
|
|
});
|
|
|
|
// One compile per test: each compile copies the whole bun binary (~1 GB under debug+ASAN),
|
|
// which by itself takes a good part of the default per-test timeout.
|
|
test.each(["output/nested/app1", "app2", "a/b/c/d/app3"])(
|
|
"compile writes the executable to outfile %s",
|
|
async relativeOutfile => {
|
|
using dir = tempDir("build-compile-outfile", {
|
|
"app.js": `console.log("Testing outfile paths");`,
|
|
});
|
|
const outfile = join(String(dir), relativeOutfile);
|
|
|
|
const result = await Bun.build({
|
|
entrypoints: [join(String(dir), "app.js")],
|
|
compile: { outfile },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.outputs.map(output => output.path)).toEqual([isWindows ? `${outfile}.exe` : outfile]);
|
|
expect(await Bun.file(result.outputs[0].path).exists()).toBe(true);
|
|
},
|
|
);
|
|
|
|
test("compile with embedded resources uses correct module prefix", async () => {
|
|
using dir = tempDir("build-compile-embedded-resources", {
|
|
"app.js": `
|
|
// This test verifies that embedded resources use the correct target-specific base path
|
|
// The module prefix should be set to the target's base path
|
|
// not the user-configured public_path
|
|
import { readFileSync } from 'fs';
|
|
|
|
// Try to read a file that would be embedded in the standalone executable
|
|
try {
|
|
const embedded = readFileSync('embedded.txt', 'utf8');
|
|
console.log('Embedded file:', embedded);
|
|
} catch (e) {
|
|
console.log('Reading embedded file');
|
|
}
|
|
`,
|
|
"embedded.txt": "This is an embedded resource",
|
|
});
|
|
|
|
// Test with default target (current platform)
|
|
const result = await Bun.build({
|
|
entrypoints: [join(dir + "", "app.js")],
|
|
compile: {
|
|
outfile: "app-with-resources",
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.outputs.length).toBe(1);
|
|
expect(result.outputs[0].path).toEndWith(isWindows ? "app-with-resources.exe" : "app-with-resources");
|
|
|
|
// The test passes if compilation succeeds - the actual embedded resource
|
|
// path handling is verified by the successful compilation
|
|
});
|
|
});
|
|
|
|
describe("compiled binary validity", () => {
|
|
test("output binary has valid executable header", async () => {
|
|
using dir = tempDir("build-compile-valid-header", {
|
|
"app.js": `console.log("hello");`,
|
|
});
|
|
|
|
const outfile = join(dir + "", "app-out");
|
|
const result = await Bun.build({
|
|
entrypoints: [join(dir + "", "app.js")],
|
|
compile: {
|
|
outfile,
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
// Read the first 4 bytes and verify it's a valid executable magic number
|
|
const file = Bun.file(result.outputs[0].path);
|
|
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer());
|
|
|
|
if (isMacOS) {
|
|
// MachO magic: 0xCFFAEDFE (little-endian)
|
|
expect(header[0]).toBe(0xcf);
|
|
expect(header[1]).toBe(0xfa);
|
|
expect(header[2]).toBe(0xed);
|
|
expect(header[3]).toBe(0xfe);
|
|
} else if (isLinux) {
|
|
// ELF magic: 0x7F 'E' 'L' 'F'
|
|
expect(header[0]).toBe(0x7f);
|
|
expect(header[1]).toBe(0x45); // 'E'
|
|
expect(header[2]).toBe(0x4c); // 'L'
|
|
expect(header[3]).toBe(0x46); // 'F'
|
|
} else if (isWindows) {
|
|
// PE magic: 'M' 'Z'
|
|
expect(header[0]).toBe(0x4d); // 'M'
|
|
expect(header[1]).toBe(0x5a); // 'Z'
|
|
}
|
|
});
|
|
|
|
test("compiled binary runs and produces expected output", async () => {
|
|
using dir = tempDir("build-compile-runs", {
|
|
"app.js": `console.log("compile-test-output");`,
|
|
});
|
|
|
|
const outfile = join(dir + "", "app-run");
|
|
const result = await Bun.build({
|
|
entrypoints: [join(dir + "", "app.js")],
|
|
compile: {
|
|
outfile,
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
await using proc = Bun.spawn({
|
|
cmd: [result.outputs[0].path],
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(stdout.trim()).toBe("compile-test-output");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
if (isLinux) {
|
|
describe("ELF section", () => {
|
|
test("compiled binary runs with execute-only permissions", async () => {
|
|
using dir = tempDir("build-compile-exec-only", {
|
|
"app.js": `console.log("exec-only-output");`,
|
|
});
|
|
|
|
const outfile = join(dir + "", "app-exec-only");
|
|
const result = await Bun.build({
|
|
entrypoints: [join(dir + "", "app.js")],
|
|
compile: {
|
|
outfile,
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
chmodSync(result.outputs[0].path, 0o111);
|
|
|
|
await using proc = Bun.spawn({
|
|
cmd: [result.outputs[0].path],
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(stdout.trim()).toBe("exec-only-output");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
test("compiled binary with large payload runs correctly", async () => {
|
|
// Generate a string payload >16KB to exceed the initial .bun section allocation
|
|
// (BUN_COMPILED is aligned to 16KB). This forces the expansion path in elf.zig
|
|
// which appends data to the end of the file and extends the writable PT_LOAD
|
|
// to cover it.
|
|
const largeString = Buffer.alloc(20000, "x").toString();
|
|
using dir = tempDir("build-compile-large-payload", {
|
|
"app.js": `const data = "${largeString}"; console.log("large-payload-" + data.length);`,
|
|
});
|
|
|
|
const outfile = join(dir + "", "app-large");
|
|
const result = await Bun.build({
|
|
entrypoints: [join(dir + "", "app.js")],
|
|
compile: {
|
|
outfile,
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
await using proc = Bun.spawn({
|
|
cmd: [result.outputs[0].path],
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(stdout).toContain("large-payload-20000");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
test("compiled binary with large payload runs with execute-only permissions", async () => {
|
|
// Same as above but also verifies execute-only works with the expansion path
|
|
const largeString = Buffer.alloc(20000, "y").toString();
|
|
using dir = tempDir("build-compile-large-exec-only", {
|
|
"app.js": `const data = "${largeString}"; console.log("large-exec-only-" + data.length);`,
|
|
});
|
|
|
|
const outfile = join(dir + "", "app-large-exec-only");
|
|
const result = await Bun.build({
|
|
entrypoints: [join(dir + "", "app.js")],
|
|
compile: {
|
|
outfile,
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
chmodSync(result.outputs[0].path, 0o111);
|
|
|
|
await using proc = Bun.spawn({
|
|
cmd: [result.outputs[0].path],
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(stdout).toContain("large-exec-only-20000");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
test("compiled binary has .bun ELF section", async () => {
|
|
using dir = tempDir("build-compile-elf-section", {
|
|
"app.js": `console.log("elf-section-test");`,
|
|
});
|
|
|
|
const outfile = join(dir + "", "app-elf-section");
|
|
const result = await Bun.build({
|
|
entrypoints: [join(dir + "", "app.js")],
|
|
compile: {
|
|
outfile,
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
// Verify .bun ELF section exists by reading section headers
|
|
const file = Bun.file(result.outputs[0].path);
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
|
|
// Parse ELF header to find section headers
|
|
const view = new DataView(bytes.buffer);
|
|
// e_shoff at offset 40 (little-endian u64)
|
|
const shoff = Number(view.getBigUint64(40, true));
|
|
// e_shentsize at offset 58
|
|
const shentsize = view.getUint16(58, true);
|
|
// e_shnum at offset 60
|
|
const shnum = view.getUint16(60, true);
|
|
// e_shstrndx at offset 62
|
|
const shstrndx = view.getUint16(62, true);
|
|
|
|
// Read .shstrtab section header to get string table
|
|
const strtabOff = shoff + shstrndx * shentsize;
|
|
const strtabFileOffset = Number(view.getBigUint64(strtabOff + 24, true));
|
|
const strtabSize = Number(view.getBigUint64(strtabOff + 32, true));
|
|
|
|
const decoder = new TextDecoder();
|
|
let foundBunSection = false;
|
|
for (let i = 0; i < shnum; i++) {
|
|
const hdrOff = shoff + i * shentsize;
|
|
const nameIdx = view.getUint32(hdrOff, true);
|
|
if (nameIdx < strtabSize) {
|
|
// Read null-terminated string from strtab
|
|
let end = strtabFileOffset + nameIdx;
|
|
while (end < bytes.length && bytes[end] !== 0) end++;
|
|
const name = decoder.decode(bytes.slice(strtabFileOffset + nameIdx, end));
|
|
if (name === ".bun") {
|
|
foundBunSection = true;
|
|
// Verify the section has non-zero size
|
|
const shSize = Number(view.getBigUint64(hdrOff + 32, true));
|
|
expect(shSize).toBeGreaterThan(0);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
expect(foundBunSection).toBe(true);
|
|
});
|
|
|
|
// 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:
|
|
//
|
|
// 1. Keep PT_GNU_STACK in the program header table (not repurposed).
|
|
// 2. Fit the .bun payload inside an existing writable PT_LOAD's
|
|
// `[p_vaddr, p_vaddr + p_memsz)` range — i.e. the writable segment
|
|
// was GROWN to cover .bun rather than a new segment being added.
|
|
//
|
|
// The gate here is purely structural (we check the ELF layout); we don't
|
|
// need a WSL1 host to validate the fix.
|
|
//
|
|
// Higher per-test timeout because `bun build --compile` copies + rewrites
|
|
// the entire bun binary (~1GB under debug+ASAN), which blows the 5s
|
|
// default.
|
|
test("compiled binary preserves PT_GNU_STACK and no late PT_LOAD for .bun (#29963)", async () => {
|
|
// Use a small payload — the shape check matters for all sizes but a
|
|
// bigger payload guarantees the expansion path actually runs.
|
|
const largeString = Buffer.alloc(20000, "z").toString();
|
|
using dir = tempDir("build-compile-wsl1-regression", {
|
|
"app.js": `const data = "${largeString}"; console.log("wsl1-regression-" + data.length);`,
|
|
});
|
|
|
|
const outfile = join(dir + "", "app-wsl1-regression");
|
|
const result = await Bun.build({
|
|
entrypoints: [join(dir + "", "app.js")],
|
|
compile: { outfile },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
|
|
const bytes = new Uint8Array(await Bun.file(result.outputs[0].path).arrayBuffer());
|
|
const view = new DataView(bytes.buffer);
|
|
|
|
// ELF64 header layout:
|
|
// e_phoff @ 32 (u64), e_phentsize @ 54 (u16), e_phnum @ 56 (u16)
|
|
const phoff = Number(view.getBigUint64(32, true));
|
|
const phentsize = view.getUint16(54, true);
|
|
const phnum = view.getUint16(56, true);
|
|
expect(phentsize).toBe(56); // sizeof(Elf64_Phdr)
|
|
|
|
// Elf64_Phdr layout:
|
|
// p_type @ 0 (u32), p_flags @ 4 (u32), p_offset @ 8 (u64),
|
|
// p_vaddr @ 16 (u64), p_paddr @ 24 (u64),
|
|
// p_filesz @ 32 (u64), p_memsz @ 40 (u64), p_align @ 48 (u64)
|
|
const PT_LOAD = 1;
|
|
const PT_GNU_STACK = 0x6474e551;
|
|
const PF_W = 2;
|
|
|
|
// Locate .bun's vaddr by walking section headers.
|
|
const shoff = Number(view.getBigUint64(40, true));
|
|
const shentsize = view.getUint16(58, true);
|
|
const shnum = view.getUint16(60, true);
|
|
const shstrndx = view.getUint16(62, true);
|
|
const strtabHdr = shoff + shstrndx * shentsize;
|
|
const strtabOff = Number(view.getBigUint64(strtabHdr + 24, true));
|
|
const strtabSize = Number(view.getBigUint64(strtabHdr + 32, true));
|
|
const decoder = new TextDecoder();
|
|
let bunAddr = 0n;
|
|
let bunSize = 0n;
|
|
for (let i = 0; i < shnum; i++) {
|
|
const hdrOff = shoff + i * shentsize;
|
|
const nameIdx = view.getUint32(hdrOff, true);
|
|
if (nameIdx >= strtabSize) continue;
|
|
let end = strtabOff + nameIdx;
|
|
while (end < bytes.length && bytes[end] !== 0) end++;
|
|
const name = decoder.decode(bytes.slice(strtabOff + nameIdx, end));
|
|
if (name === ".bun") {
|
|
bunAddr = view.getBigUint64(hdrOff + 16, true); // sh_addr
|
|
bunSize = view.getBigUint64(hdrOff + 32, true); // sh_size
|
|
break;
|
|
}
|
|
}
|
|
expect(bunAddr).not.toBe(0n);
|
|
expect(bunSize).toBeGreaterThan(0n);
|
|
|
|
// Walk program headers: count PT_LOADs, require PT_GNU_STACK to still
|
|
// be present, and find the writable PT_LOAD containing .bun.
|
|
let hasGnuStack = false;
|
|
let loadCount = 0;
|
|
let writableLoadCoversBun = false;
|
|
for (let i = 0; i < phnum; i++) {
|
|
const off = phoff + i * phentsize;
|
|
const pType = view.getUint32(off, true);
|
|
const pFlags = view.getUint32(off + 4, true);
|
|
const pVaddr = view.getBigUint64(off + 16, true);
|
|
const pMemsz = view.getBigUint64(off + 40, true);
|
|
|
|
if (pType === PT_GNU_STACK) hasGnuStack = true;
|
|
if (pType === PT_LOAD) {
|
|
loadCount++;
|
|
if ((pFlags & PF_W) !== 0 && pVaddr <= bunAddr && bunAddr + bunSize <= pVaddr + pMemsz) {
|
|
writableLoadCoversBun = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// #29963: PT_GNU_STACK must NOT be repurposed into a PT_LOAD.
|
|
expect(hasGnuStack).toBe(true);
|
|
// #29963: the writable PT_LOAD must have been grown to cover .bun,
|
|
// rather than a new late PT_LOAD being appended.
|
|
expect(writableLoadCoversBun).toBe(true);
|
|
// A stock bun has 3 PT_LOAD segments; the fix must not add a 4th.
|
|
expect(loadCount).toBe(3);
|
|
// JSC bytecode cache requires 128-byte-aligned deserialization input.
|
|
// StandaloneModuleGraph writes bytecode at payload offset 120 assuming
|
|
// the `[u64 size]` header sits at a 128-byte-aligned vaddr (so bytecode
|
|
// lands at vaddr + 8 + 120, which is 128-aligned). A new_vaddr that
|
|
// inherits the RW segment's non-128 residue SIGSEGVs JSC on aarch64.
|
|
expect(bunAddr % 128n).toBe(0n);
|
|
|
|
// Sanity: the binary still runs and produces the expected output.
|
|
await using proc = Bun.spawn({
|
|
cmd: [result.outputs[0].path],
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
expect(stderr).toBe("");
|
|
expect(stdout).toContain("wsl1-regression-20000");
|
|
expect(exitCode).toBe(0);
|
|
}, 60_000);
|
|
|
|
// Regression guard for #31023. On NixOS, `autoPatchelfHook` runs
|
|
// `patchelf --set-interpreter` on the installed bun binary. Patchelf
|
|
// inserts a *new* writable PT_LOAD at the front of the program-header
|
|
// table (to hold the relocated PHDR + .interp), so the template bun
|
|
// has TWO writable PT_LOADs. `write_bun_section` used to pick the
|
|
// first writable PT_LOAD and extend it — that's patchelf's small
|
|
// segment, unrelated to .bun — producing an output whose grown
|
|
// segment overlaps the read-only and executable PT_LOADs at
|
|
// conflicting vaddrs. The kernel ELF loader mmap'd garbage over .bun
|
|
// at its runtime address and the compiled binary segfaulted on exec.
|
|
//
|
|
// We simulate the NixOS layout by running `patchelf
|
|
// --set-interpreter` on the bun binary (exactly what
|
|
// autoPatchelfHook does) and then using `--compile-executable-path`
|
|
// to drive `bun build --compile` off it. The resulting output must
|
|
// (a) have the .bun section inside a writable PT_LOAD whose extent
|
|
// doesn't cross another PT_LOAD, and (b) actually run.
|
|
const patchelf = Bun.which("patchelf");
|
|
const ldso =
|
|
process.arch === "arm64"
|
|
? isMusl
|
|
? "/lib/ld-musl-aarch64.so.1"
|
|
: "/lib/ld-linux-aarch64.so.1"
|
|
: isMusl
|
|
? "/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;
|
|
}
|
|
|
|
test.skipIf(!patchelf || !existsSync(ldso) || hostLooksNix())(
|
|
"compiled binary works when template bun has patchelf-inserted RW PT_LOAD (#31023)",
|
|
async () => {
|
|
using dir = tempDir("build-compile-patchelf-rw-regression", {
|
|
"app.js": `console.log("patchelf-regression-ok");`,
|
|
});
|
|
const cwd = String(dir);
|
|
|
|
// Copy bun and patchelf it — autoPatchelfHook's signature move.
|
|
// Any real interpreter works; we just need patchelf to insert its
|
|
// new writable PT_LOAD at the front of the phdr table.
|
|
const patchedBun = join(cwd, "patched-bun");
|
|
cpSync(bunExe(), patchedBun);
|
|
chmodSync(patchedBun, 0o755);
|
|
{
|
|
const r = Bun.spawnSync({
|
|
cmd: [patchelf!, "--set-interpreter", ldso, patchedBun],
|
|
stderr: "pipe",
|
|
});
|
|
expect(r.stderr.toString()).toBe("");
|
|
expect(r.exitCode).toBe(0);
|
|
}
|
|
|
|
// Sanity: the patched bun really does have two writable PT_LOADs.
|
|
// Otherwise the test is vacuous (it would exercise the same path
|
|
// as the stock-bun tests above).
|
|
{
|
|
const bytes = new Uint8Array(await Bun.file(patchedBun).arrayBuffer());
|
|
const view = new DataView(bytes.buffer);
|
|
const phoff = Number(view.getBigUint64(32, true));
|
|
const phentsize = view.getUint16(54, true);
|
|
const phnum = view.getUint16(56, true);
|
|
let writableLoads = 0;
|
|
for (let i = 0; i < phnum; i++) {
|
|
const off = phoff + i * phentsize;
|
|
const pType = view.getUint32(off, true);
|
|
const pFlags = view.getUint32(off + 4, true);
|
|
if (pType === 1 /* PT_LOAD */ && (pFlags & 2) !== 0 /* PF_W */) writableLoads++;
|
|
}
|
|
expect(writableLoads).toBeGreaterThanOrEqual(2);
|
|
}
|
|
|
|
// Drive bun build --compile off the patched template.
|
|
const outfile = join(cwd, "app-out");
|
|
const build = Bun.spawnSync({
|
|
cmd: [
|
|
bunExe(),
|
|
"build",
|
|
"--compile",
|
|
"--compile-executable-path",
|
|
patchedBun,
|
|
join(cwd, "app.js"),
|
|
"--outfile",
|
|
outfile,
|
|
],
|
|
env: bunEnv,
|
|
cwd,
|
|
stderr: "pipe",
|
|
stdout: "pipe",
|
|
});
|
|
expect(build.stderr.toString()).not.toContain("error:");
|
|
expect(build.exitCode).toBe(0);
|
|
|
|
// Structural check on the output: the writable PT_LOAD that
|
|
// contains .bun must not overlap any other PT_LOAD. Before the
|
|
// fix, the grown front PT_LOAD extended past the R and R-E
|
|
// PT_LOADs, which is exactly the corruption that segfaulted.
|
|
const bytes = new Uint8Array(await Bun.file(outfile).arrayBuffer());
|
|
const view = new DataView(bytes.buffer);
|
|
const phoff = Number(view.getBigUint64(32, true));
|
|
const phentsize = view.getUint16(54, true);
|
|
const phnum = view.getUint16(56, true);
|
|
const shoff = Number(view.getBigUint64(40, true));
|
|
const shentsize = view.getUint16(58, true);
|
|
const shnum = view.getUint16(60, true);
|
|
const shstrndx = view.getUint16(62, true);
|
|
const strtabHdr = shoff + shstrndx * shentsize;
|
|
const strtabOff = Number(view.getBigUint64(strtabHdr + 24, true));
|
|
const strtabSize = Number(view.getBigUint64(strtabHdr + 32, true));
|
|
|
|
// Find .bun's vaddr.
|
|
const decoder = new TextDecoder();
|
|
let bunAddr = 0n;
|
|
for (let i = 0; i < shnum; i++) {
|
|
const hdrOff = shoff + i * shentsize;
|
|
const nameIdx = view.getUint32(hdrOff, true);
|
|
if (nameIdx >= strtabSize) continue;
|
|
let end = strtabOff + nameIdx;
|
|
while (end < bytes.length && bytes[end] !== 0) end++;
|
|
const name = decoder.decode(bytes.slice(strtabOff + nameIdx, end));
|
|
if (name === ".bun") {
|
|
bunAddr = view.getBigUint64(hdrOff + 16, true);
|
|
break;
|
|
}
|
|
}
|
|
expect(bunAddr).not.toBe(0n);
|
|
|
|
// Collect all PT_LOAD ranges; find the one that covers .bun and
|
|
// assert it doesn't overlap any of the others.
|
|
type LoadSeg = { vaddr: bigint; end: bigint; writable: boolean };
|
|
const loads: LoadSeg[] = [];
|
|
for (let i = 0; i < phnum; i++) {
|
|
const off = phoff + i * phentsize;
|
|
if (view.getUint32(off, true) !== 1 /* PT_LOAD */) continue;
|
|
const pFlags = view.getUint32(off + 4, true);
|
|
const pVaddr = view.getBigUint64(off + 16, true);
|
|
const pMemsz = view.getBigUint64(off + 40, true);
|
|
loads.push({ vaddr: pVaddr, end: pVaddr + pMemsz, writable: (pFlags & 2) !== 0 });
|
|
}
|
|
const bunLoadIdx = loads.findIndex(s => s.writable && s.vaddr <= bunAddr && bunAddr < s.end);
|
|
expect(bunLoadIdx).toBeGreaterThanOrEqual(0);
|
|
const bunLoad = loads[bunLoadIdx];
|
|
for (let i = 0; i < loads.length; i++) {
|
|
if (i === bunLoadIdx) continue;
|
|
const other = loads[i];
|
|
// Disjoint: either bunLoad ends before other starts, or other
|
|
// ends before bunLoad starts.
|
|
const disjoint = bunLoad.end <= other.vaddr || other.end <= bunLoad.vaddr;
|
|
expect(disjoint).toBe(true);
|
|
}
|
|
|
|
// And the binary actually runs — the ultimate behavioral check.
|
|
await using proc = Bun.spawn({
|
|
cmd: [outfile],
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
expect(stdout.trim()).toBe("patchelf-regression-ok");
|
|
expect(exitCode).toBe(0);
|
|
},
|
|
180_000,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Regression guard for the standalone-module-graph ELF probe on Android.
|
|
//
|
|
// Spec: src/standalone_graph/StandaloneModuleGraph.zig — `fromExecutable()`
|
|
// gates the ELF `.bun` reader on `Environment.isLinux or Environment.isFreeBSD`.
|
|
// Zig's `isLinux` (builtin.target.os.tag == .linux) is TRUE on Android, so
|
|
// Android takes the ELF path and the trailing `comptime unreachable` is dead.
|
|
//
|
|
// In Rust, `target_os = "linux"` and `target_os = "android"` are distinct cfg
|
|
// values. A naive port of the Zig gate as
|
|
// #[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
|
// silently excludes Android and falls through to the catch-all
|
|
// `unreachable!()`, so every `bun build --compile` binary panics at startup
|
|
// on Android instead of loading its embedded module graph.
|
|
//
|
|
// This test only runs on an Android host. It compiles a trivial app and
|
|
// asserts the resulting binary starts, finds its graph, and runs the entry —
|
|
// i.e. the ELF arm was taken, not `unreachable!()`.
|
|
if (process.platform === "android") {
|
|
describe("ELF section (Android)", () => {
|
|
test("compiled standalone binary loads its module graph on Android", async () => {
|
|
using dir = tempDir("build-compile-android-elf", {
|
|
"app.js": `console.log("android-standalone-ok");`,
|
|
});
|
|
|
|
const outfile = join(String(dir), "app-android");
|
|
|
|
await using build = Bun.spawn({
|
|
cmd: [bunExe(), "build", "--compile", join(String(dir), "app.js"), "--outfile", outfile],
|
|
env: bunEnv,
|
|
cwd: String(dir),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [, buildStderr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]);
|
|
expect(buildStderr).not.toContain("error:");
|
|
expect(buildExit).toBe(0);
|
|
|
|
await using proc = Bun.spawn({
|
|
cmd: [outfile],
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
// If the Rust cfg-gate diverges from Zig's `Environment.isLinux`, the
|
|
// process panics with `internal error: entered unreachable code` before
|
|
// any user JS runs. Assert the spec behavior: graph found, entry ran.
|
|
expect(stderr).not.toContain("unreachable");
|
|
expect(stdout.trim()).toBe("android-standalone-ok");
|
|
expect(exitCode).toBe(0);
|
|
}, 60_000);
|
|
});
|
|
}
|
|
|
|
// A standalone compiled binary bypasses `Arguments::parse` (no `--cwd`/global
|
|
// flags, no baked exec-argv), so `absolute_working_dir` stays unset and the
|
|
// FIRST `getcwd` of the whole startup is the one inside `Transpiler::init`.
|
|
// When the cwd has been deleted that `getcwd` fails with ENOENT; the bug was
|
|
// that the per-VM init hook swallowed the error and left `vm.transpiler`
|
|
// zeroed, so the next read (`configure_defines` → `run_env_loader`) hit a null
|
|
// deref and the binary crashed (the segfault users saw launching a compiled
|
|
// CLI from a directory that had been removed). It must instead exit cleanly
|
|
// with the ENOENT message.
|
|
//
|
|
// POSIX-only: a process can keep a deleted directory as its cwd until the last
|
|
// fd to it closes, whereas Windows refuses to remove a directory that is any
|
|
// process's cwd — so the scenario is unreachable there. The cwd has to be
|
|
// removed AFTER the process starts, which `Bun.spawn`'s `cwd` can't do, so a
|
|
// shell wrapper `cd`s in, `rmdir`s, then execs the binary (how a user hits it).
|
|
describe("compiled binary in a deleted cwd", () => {
|
|
test.if(isPosix)(
|
|
"exits cleanly instead of crashing",
|
|
async () => {
|
|
using dir = tempDir("build-compile-deleted-cwd", {
|
|
"app.js": `console.log("should-not-run");`,
|
|
});
|
|
const outfile = join(String(dir), "app");
|
|
|
|
await using build = Bun.spawn({
|
|
cmd: [bunExe(), "build", "--compile", join(String(dir), "app.js"), "--outfile", outfile],
|
|
env: bunEnv,
|
|
cwd: String(dir),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [, buildStderr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]);
|
|
expect(buildStderr).not.toContain("error:");
|
|
expect(buildExit).toBe(0);
|
|
|
|
// A fresh directory to stand in and delete — NOT `dir`, which holds the
|
|
// compiled binary we still need to exec.
|
|
using cwdDir = tempDir("build-compile-gone-cwd", {});
|
|
const gone = String(cwdDir);
|
|
|
|
await using proc = Bun.spawn({
|
|
cmd: ["/bin/sh", "-c", `cd "${gone}" && rmdir "${gone}" && exec "${outfile}"`],
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(stdout).toBe("");
|
|
expect(stderr).toContain("The current working directory was deleted");
|
|
expect(exitCode).toBe(1);
|
|
},
|
|
60_000,
|
|
);
|
|
});
|
|
|
|
// file command test works well
|