From 6f03fc54516b9a6ff93e4ab076b0a19d70379251 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Thu, 12 Feb 2026 03:57:45 +0000 Subject: [PATCH 1/8] fix(compile): use ELF section for standalone binaries on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone executables on Linux previously read their embedded module graph from /proc/self/exe at startup, which fails when the binary has execute-only permissions (chmod 111). This mirrors the approach already used on macOS (__BUN,__bun section) and Windows (.bun PE section). Build time: places a BUN_COMPILED symbol in a .bun ELF section via __attribute__((section(".bun"))). At bun build --compile time, appends the module graph to the end of the file, converts PT_GNU_STACK into a PT_LOAD segment to map it, and stores the new vaddr at the original BUN_COMPILED location. Runtime: BUN_COMPILED.size holds either 0 (not standalone) or the vaddr of the appended data. The kernel maps it via PT_LOAD during execve, so the runtime just dereferences a pointer — zero file I/O, no read permission needed. Co-Authored-By: Claude Opus 4.6 --- src/StandaloneModuleGraph.zig | 269 ++++++++----------------- src/bun.js/bindings/c-bindings.cpp | 13 ++ src/bun.zig | 1 + src/elf.zig | 230 +++++++++++++++++++++ test/bundler/bun-build-compile.test.ts | 154 ++++++++++++++ 5 files changed, 477 insertions(+), 190 deletions(-) create mode 100644 src/elf.zig diff --git a/src/StandaloneModuleGraph.zig b/src/StandaloneModuleGraph.zig index dd63115ee9f4..de43414935a7 100644 --- a/src/StandaloneModuleGraph.zig +++ b/src/StandaloneModuleGraph.zig @@ -154,6 +154,22 @@ pub const StandaloneModuleGraph = struct { } }; + const ELF = struct { + pub extern "C" fn Bun__getStandaloneModuleGraphELFVaddr() ?*align(1) u64; + + pub fn getData() ?[]const u8 { + const vaddr = (Bun__getStandaloneModuleGraphELFVaddr() orelse return null).*; + if (vaddr == 0) return null; + // 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] + const target: [*]const u8 = @ptrFromInt(vaddr); + const payload_len = std.mem.readInt(u64, target[0..8], .little); + if (payload_len < 8) return null; + return target[8..][0..payload_len]; + } + }; + pub const File = struct { name: []const u8 = "", loader: bun.options.Loader, @@ -885,6 +901,56 @@ pub const StandaloneModuleGraph = struct { } return cloned_executable_fd; }, + .linux => { + // ELF section approach: find .bun section and expand it + const input_result = bun.sys.File.readToEnd(.{ .handle = cloned_executable_fd }, bun.default_allocator); + if (input_result.err) |err| { + Output.prettyErrorln("Error reading executable: {f}", .{err}); + cleanup(zname, cloned_executable_fd); + return bun.invalid_fd; + } + + const elf_file = bun.elf.ElfFile.init(bun.default_allocator, input_result.bytes.items) catch |err| { + Output.prettyErrorln("Error initializing ELF file: {}", .{err}); + cleanup(zname, cloned_executable_fd); + return bun.invalid_fd; + }; + defer elf_file.deinit(); + + elf_file.writeBunSection(bytes) catch |err| { + Output.prettyErrorln("Error writing .bun section to ELF: {}", .{err}); + cleanup(zname, cloned_executable_fd); + return bun.invalid_fd; + }; + input_result.bytes.deinit(); + + switch (Syscall.setFileOffset(cloned_executable_fd, 0)) { + .err => |err| { + Output.prettyErrorln("Error seeking to start of temporary file: {f}", .{err}); + cleanup(zname, cloned_executable_fd); + return bun.invalid_fd; + }, + else => {}, + } + + // Write the modified ELF data back to the file + const write_file = bun.sys.File{ .handle = cloned_executable_fd }; + switch (write_file.writeAll(elf_file.data.items)) { + .err => |err| { + Output.prettyErrorln("Error writing ELF file: {f}", .{err}); + cleanup(zname, cloned_executable_fd); + return bun.invalid_fd; + }, + .result => {}, + } + // Truncate the file to the exact size of the modified ELF + _ = Syscall.ftruncate(cloned_executable_fd, @intCast(elf_file.data.items.len)); + + if (comptime !Environment.isWindows) { + _ = bun.c.fchmod(cloned_executable_fd.native(), 0o777); + } + return cloned_executable_fd; + }, else => { var total_byte_count: usize = undefined; if (Environment.isWindows) { @@ -1261,99 +1327,23 @@ pub const StandaloneModuleGraph = struct { return try fromBytesAlloc(allocator, @constCast(pe_bytes), offsets); } - // Do not invoke libuv here. - const self_exe = openSelf() catch return null; - defer self_exe.close(); - - var trailer_bytes: [4096]u8 = undefined; - std.posix.lseek_END(self_exe.cast(), -4096) catch return null; - - var read_amount: usize = 0; - while (read_amount < trailer_bytes.len) { - switch (Syscall.read(self_exe, trailer_bytes[read_amount..])) { - .result => |read| { - if (read == 0) return null; - - read_amount += read; - }, - .err => { - return null; - }, - } - } - - if (read_amount < trailer.len + @sizeOf(usize) + @sizeOf(Offsets)) - // definitely missing data - return null; - - var end = @as([]u8, &trailer_bytes).ptr + read_amount - @sizeOf(usize); - const total_byte_count: usize = @as(usize, @bitCast(end[0..8].*)); - - if (total_byte_count > std.math.maxInt(u32) or total_byte_count < 4096) { - // sanity check: the total byte count should never be more than 4 GB - // bun is at least like 30 MB so if it reports a size less than 4096 bytes then something is wrong - return null; - } - end -= trailer.len; - - if (!bun.strings.hasPrefixComptime(end[0..trailer.len], trailer)) { - // invalid trailer - return null; - } - - end -= @sizeOf(Offsets); - - const offsets: Offsets = std.mem.bytesAsValue(Offsets, end[0..@sizeOf(Offsets)]).*; - if (offsets.byte_count >= total_byte_count) { - // if we hit this branch then the file is corrupted and we should just give up - return null; - } - - var to_read = try bun.default_allocator.alloc(u8, offsets.byte_count); - var to_read_from = to_read; - - // Reading the data and making sure it's page-aligned + won't crash due - // to out of bounds using mmap() is very complicated. - // we just read the whole thing into memory for now. - // at the very least - // if you have not a ton of code, we only do a single read() call - if (Environment.allow_assert or offsets.byte_count > 1024 * 3) { - const offset_from_end = trailer_bytes.len - (@intFromPtr(end) - @intFromPtr(@as([]u8, &trailer_bytes).ptr)); - std.posix.lseek_END(self_exe.cast(), -@as(i64, @intCast(offset_from_end + offsets.byte_count))) catch return null; - - if (comptime Environment.allow_assert) { - // actually we just want to verify this logic is correct in development - if (offsets.byte_count <= 1024 * 3) { - to_read_from = try bun.default_allocator.alloc(u8, offsets.byte_count); - } - } - - var remain = to_read_from; - while (remain.len > 0) { - switch (Syscall.read(self_exe, remain)) { - .result => |read| { - if (read == 0) return null; - - remain = remain[read..]; - }, - .err => { - bun.default_allocator.free(to_read); - return null; - }, - } + if (comptime Environment.isLinux) { + const elf_bytes = ELF.getData() orelse return null; + if (elf_bytes.len < @sizeOf(Offsets) + trailer.len) { + Output.debugWarn("bun standalone module graph is too small to be valid", .{}); + return null; } - } - - if (offsets.byte_count <= 1024 * 3) { - // we already have the bytes - end -= offsets.byte_count; - @memcpy(to_read[0..offsets.byte_count], end[0..offsets.byte_count]); - if (comptime Environment.allow_assert) { - bun.assert(bun.strings.eqlLong(to_read, end[0..offsets.byte_count], true)); + const elf_bytes_slice = elf_bytes[elf_bytes.len - @sizeOf(Offsets) - trailer.len ..]; + const trailer_bytes = elf_bytes[elf_bytes.len - trailer.len ..][0..trailer.len]; + if (!bun.strings.eqlComptime(trailer_bytes, trailer)) { + Output.debugWarn("bun standalone module graph has invalid trailer", .{}); + return null; } + const offsets = std.mem.bytesAsValue(Offsets, elf_bytes_slice).*; + return try fromBytesAlloc(allocator, @constCast(elf_bytes), offsets); } - return try fromBytesAlloc(allocator, to_read, offsets); + comptime unreachable; } /// Allocates a StandaloneModuleGraph on the heap, populates it from bytes, sets it globally, and returns the pointer. @@ -1364,107 +1354,6 @@ pub const StandaloneModuleGraph = struct { return graph_ptr; } - /// heuristic: `bun build --compile` won't be supported if the name is "bun", "bunx", or "node". - /// this is a cheap way to avoid the extra overhead of opening the executable, and also just makes sense. - fn isBuiltInExe(comptime T: type, argv0: []const T) bool { - if (argv0.len == 0) return false; - - if (argv0.len == 3) { - if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bun"), false)) { - return true; - } - } - - if (argv0.len == 4) { - if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bunx"), false)) { - return true; - } - - if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "node"), false)) { - return true; - } - } - - if (comptime Environment.isDebug) { - if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bun-debug"), true)) { - return true; - } - if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bun-debugx"), true)) { - return true; - } - } - - return false; - } - - fn openSelf() std.fs.OpenSelfExeError!bun.FileDescriptor { - if (!Environment.isWindows) { - const argv = bun.argv; - if (argv.len > 0) { - if (isBuiltInExe(u8, argv[0])) { - return error.FileNotFound; - } - } - } - - switch (Environment.os) { - .linux => { - if (std.fs.openFileAbsoluteZ("/proc/self/exe", .{})) |easymode| { - return .fromStdFile(easymode); - } else |_| { - if (bun.argv.len > 0) { - // The user doesn't have /proc/ mounted, so now we just guess and hope for the best. - var whichbuf: bun.PathBuffer = undefined; - if (bun.which( - &whichbuf, - bun.env_var.PATH.get() orelse return error.FileNotFound, - "", - bun.argv[0], - )) |path| { - return .fromStdFile(try std.fs.cwd().openFileZ(path, .{})); - } - } - - return error.FileNotFound; - } - }, - .mac => { - // Use of MAX_PATH_BYTES here is valid as the resulting path is immediately - // opened with no modification. - const self_exe_path = try bun.selfExePath(); - const file = try std.fs.openFileAbsoluteZ(self_exe_path.ptr, .{}); - return .fromStdFile(file); - }, - .windows => { - const image_path_unicode_string = std.os.windows.peb().ProcessParameters.ImagePathName; - const image_path = image_path_unicode_string.Buffer.?[0 .. image_path_unicode_string.Length / 2]; - - var nt_path_buf: bun.WPathBuffer = undefined; - const nt_path = bun.strings.addNTPathPrefixIfNeeded(&nt_path_buf, image_path); - - const basename_start = std.mem.lastIndexOfScalar(u16, nt_path, '\\') orelse - return error.FileNotFound; - const basename = nt_path[basename_start + 1 .. nt_path.len - ".exe".len]; - if (isBuiltInExe(u16, basename)) { - return error.FileNotFound; - } - - return bun.sys.openFileAtWindows( - .cwd(), - nt_path, - .{ - .access_mask = w.SYNCHRONIZE | w.GENERIC_READ, - .disposition = w.FILE_OPEN, - .options = w.FILE_SYNCHRONOUS_IO_NONALERT | w.FILE_OPEN_REPARSE_POINT, - }, - ).unwrap() catch { - return error.FileNotFound; - }; - }, - .wasm => @compileError("TODO"), - } - } - /// Source map serialization in the bundler is specially designed to be /// loaded in memory as is. Source contents are compressed with ZSTD to /// reduce the file size, and mappings are stored as uncompressed VLQ. diff --git a/src/bun.js/bindings/c-bindings.cpp b/src/bun.js/bindings/c-bindings.cpp index 71c3e46c79f6..b7a9a148d647 100644 --- a/src/bun.js/bindings/c-bindings.cpp +++ b/src/bun.js/bindings/c-bindings.cpp @@ -910,6 +910,8 @@ extern "C" void Bun__signpost_emit(os_log_t log, os_signpost_type_t type, os_sig #undef EMIT_SIGNPOST #undef FOR_EACH_TRACE_EVENT +#endif // OS(DARWIN) signpost code + #define BLOB_HEADER_ALIGNMENT 16 * 1024 extern "C" { @@ -919,6 +921,8 @@ struct BlobHeader { } __attribute__((aligned(BLOB_HEADER_ALIGNMENT))); } +#if OS(DARWIN) + extern "C" BlobHeader __attribute__((section("__BUN,__bun"))) BUN_COMPILED = { 0, 0 }; extern "C" uint64_t* Bun__getStandaloneModuleGraphMachoLength() @@ -926,6 +930,15 @@ extern "C" uint64_t* Bun__getStandaloneModuleGraphMachoLength() return &BUN_COMPILED.size; } +#elif defined(__linux__) + +extern "C" BlobHeader __attribute__((section(".bun"), aligned(BLOB_HEADER_ALIGNMENT), used)) BUN_COMPILED = { 0 }; + +extern "C" uint64_t* Bun__getStandaloneModuleGraphELFVaddr() +{ + return &BUN_COMPILED.size; +} + #elif defined(_WIN32) // Windows PE section handling #include diff --git a/src/bun.zig b/src/bun.zig index 564ebe0c8747..96a679f8de7e 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -3704,6 +3704,7 @@ pub fn freeSensitive(allocator: std.mem.Allocator, slice: anytype) void { pub const macho = @import("./macho.zig"); pub const pe = @import("./pe.zig"); +pub const elf = @import("./elf.zig"); pub const valkey = @import("./valkey/index.zig"); pub const highway = @import("./highway.zig"); diff --git a/src/elf.zig b/src/elf.zig new file mode 100644 index 000000000000..e30ea8356065 --- /dev/null +++ b/src/elf.zig @@ -0,0 +1,230 @@ +/// ELF file manipulation for `bun build --compile` on Linux. +/// +/// Analogous to `macho.zig` (macOS) and `pe.zig` (Windows). +/// Finds the `.bun` ELF section (placed by a linker symbol in c-bindings.cpp) +/// and expands it to hold the standalone module graph data. +/// +/// Must work on any host platform (macOS, Windows, Linux) for cross-compilation. +pub const ElfFile = struct { + data: std.array_list.Managed(u8), + allocator: Allocator, + + pub fn init(allocator: Allocator, elf_data: []const u8) !*ElfFile { + if (elf_data.len < @sizeOf(Elf64_Ehdr)) return error.InvalidElfFile; + + const ehdr = readEhdr(elf_data); + + // Validate ELF magic + if (!std.mem.eql(u8, ehdr.e_ident[0..4], "\x7fELF")) return error.InvalidElfFile; + + // Must be 64-bit + if (ehdr.e_ident[elf.EI_CLASS] != elf.ELFCLASS64) return error.Not64Bit; + + // Must be little-endian (bun only supports x64 + arm64, both LE) + if (ehdr.e_ident[elf.EI_DATA] != elf.ELFDATA2LSB) return error.NotLittleEndian; + + var data = try std.array_list.Managed(u8).initCapacity(allocator, elf_data.len); + try data.appendSlice(elf_data); + + const self = try allocator.create(ElfFile); + errdefer allocator.destroy(self); + + self.* = .{ + .data = data, + .allocator = allocator, + }; + + return self; + } + + pub fn deinit(self: *ElfFile) void { + self.data.deinit(); + self.allocator.destroy(self); + } + + /// Find the `.bun` section and write `payload` to the end of the ELF file, + /// creating a new PT_LOAD segment (from PT_GNU_STACK) to map it. Stores the + /// new segment's vaddr at the original BUN_COMPILED location so the runtime + /// can dereference it directly. + /// + /// We always append rather than writing in-place because .bun is in the middle + /// of a PT_LOAD segment — sections like .dynamic, .got, .got.plt come after it, + /// and expanding in-place would invalidate their absolute virtual addresses. + pub fn writeBunSection(self: *ElfFile, payload: []const u8) !void { + const ehdr = readEhdr(self.data.items); + const bun_section_offset = try self.findBunSection(ehdr); + const page_size = pageSize(ehdr); + + const header_size: u64 = @sizeOf(u64); + const new_content_size: u64 = header_size + payload.len; + const aligned_new_size = alignUp(new_content_size, page_size); + + // Find the highest virtual address across all PT_LOAD segments + var max_vaddr_end: u64 = 0; + const phdr_size = @sizeOf(Elf64_Phdr); + for (0..ehdr.e_phnum) |i| { + const phdr_offset = @as(usize, @intCast(ehdr.e_phoff)) + i * phdr_size; + const phdr = std.mem.bytesAsValue(Elf64_Phdr, self.data.items[phdr_offset..][0..phdr_size]).*; + if (phdr.p_type == elf.PT_LOAD) { + const vaddr_end = phdr.p_vaddr + phdr.p_memsz; + if (vaddr_end > max_vaddr_end) { + max_vaddr_end = vaddr_end; + } + } + } + + // The new segment's virtual address: after all existing mappings, page-aligned + const new_vaddr = alignUp(max_vaddr_end, page_size); + + // The new data goes at the end of the file, page-aligned + const new_file_offset = alignUp(self.data.items.len, page_size); + + // Grow the buffer to hold the new data + section header table after it + const shdr_table_size = @as(u64, ehdr.e_shnum) * @sizeOf(Elf64_Shdr); + const new_shdr_offset = new_file_offset + aligned_new_size; + const total_new_size = new_shdr_offset + shdr_table_size; + + const old_file_size = self.data.items.len; + try self.data.ensureTotalCapacity(total_new_size); + self.data.items.len = total_new_size; + + // Zero the gap between old file end and new data (alignment padding). + // Without this, uninitialized allocator memory would leak into the output. + if (new_file_offset > old_file_size) { + @memset(self.data.items[old_file_size..new_file_offset], 0); + } + + // Copy the section header table to its new location + const old_shdr_offset = ehdr.e_shoff; + bun.memmove( + self.data.items[new_shdr_offset..][0..shdr_table_size], + self.data.items[old_shdr_offset..][0..shdr_table_size], + ); + + // Update e_shoff to the new section header table location + self.writeEhdrShoff(new_shdr_offset); + + // Write the payload at the new location: [u64 LE size][data][zero padding] + std.mem.writeInt(u64, self.data.items[new_file_offset..][0..8], @intCast(payload.len), .little); + @memcpy(self.data.items[new_file_offset + header_size ..][0..payload.len], payload); + + // Zero the padding between payload end and section header table + const padding_start = new_file_offset + new_content_size; + if (new_shdr_offset > padding_start) { + @memset(self.data.items[padding_start..new_shdr_offset], 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". + std.mem.writeInt(u64, self.data.items[bun_section_offset..][0..8], new_vaddr, .little); + + // Find PT_GNU_STACK and convert it to PT_LOAD for the new .bun data. + // PT_GNU_STACK only controls stack executability; on modern kernels the + // stack defaults to non-executable without it, so repurposing is safe. + var found_gnu_stack = false; + for (0..ehdr.e_phnum) |i| { + const phdr_offset = @as(usize, @intCast(ehdr.e_phoff)) + i * phdr_size; + const phdr = std.mem.bytesAsValue(Elf64_Phdr, self.data.items[phdr_offset..][0..phdr_size]).*; + + if (phdr.p_type == elf.PT_GNU_STACK) { + // Convert to PT_LOAD + const new_phdr: Elf64_Phdr = .{ + .p_type = elf.PT_LOAD, + .p_flags = elf.PF_R, // read-only + .p_offset = new_file_offset, + .p_vaddr = new_vaddr, + .p_paddr = new_vaddr, + .p_filesz = aligned_new_size, + .p_memsz = aligned_new_size, + .p_align = page_size, + }; + @memcpy(self.data.items[phdr_offset..][0..phdr_size], std.mem.asBytes(&new_phdr)); + found_gnu_stack = true; + break; + } + } + + if (!found_gnu_stack) { + return error.NoGnuStackSegment; + } + } + + pub fn write(self: *const ElfFile, writer: anytype) !void { + try writer.writeAll(self.data.items); + } + + // --- Internal helpers --- + + /// Returns the file offset (sh_offset) of the `.bun` section. + fn findBunSection(self: *const ElfFile, ehdr: Elf64_Ehdr) !u64 { + const shdr_size = @sizeOf(Elf64_Shdr); + const shdr_table_offset = ehdr.e_shoff; + const shnum = ehdr.e_shnum; + + if (shnum == 0) return error.BunSectionNotFound; + if (shdr_table_offset + @as(u64, shnum) * shdr_size > self.data.items.len) + return error.InvalidElfFile; + + // Read the .shstrtab section to get section names + const shstrtab_shdr = self.readShdr(shdr_table_offset, ehdr.e_shstrndx); + const strtab_offset = shstrtab_shdr.sh_offset; + const strtab_size = shstrtab_shdr.sh_size; + + if (strtab_offset + strtab_size > self.data.items.len) return error.InvalidElfFile; + const strtab = self.data.items[strtab_offset..][0..strtab_size]; + + // Search for .bun section + for (0..shnum) |i| { + const shdr = self.readShdr(shdr_table_offset, @intCast(i)); + const name_offset = shdr.sh_name; + + if (name_offset < strtab.len) { + const name = std.mem.sliceTo(strtab[name_offset..], 0); + if (std.mem.eql(u8, name, ".bun")) { + return shdr.sh_offset; + } + } + } + + return error.BunSectionNotFound; + } + + fn readShdr(self: *const ElfFile, table_offset: u64, index: u16) Elf64_Shdr { + const offset = table_offset + @as(u64, index) * @sizeOf(Elf64_Shdr); + return std.mem.bytesAsValue(Elf64_Shdr, self.data.items[offset..][0..@sizeOf(Elf64_Shdr)]).*; + } + + fn writeEhdrShoff(self: *ElfFile, new_shoff: u64) void { + // e_shoff is at offset 40 in Elf64_Ehdr + std.mem.writeInt(u64, self.data.items[40..][0..8], new_shoff, .little); + } + + fn pageSize(ehdr: Elf64_Ehdr) u64 { + return switch (ehdr.e_machine) { + .AARCH64, .PPC64 => 0x10000, // 64KB + else => 0x1000, // 4KB + }; + } +}; + +fn readEhdr(data: []const u8) Elf64_Ehdr { + return std.mem.bytesAsValue(Elf64_Ehdr, data[0..@sizeOf(Elf64_Ehdr)]).*; +} + +fn alignUp(value: u64, alignment: u64) u64 { + if (alignment == 0) return value; + const mask = alignment - 1; + return (value + mask) & ~mask; +} + +const Elf64_Ehdr = elf.Elf64_Ehdr; +const Elf64_Phdr = elf.Elf64_Phdr; +const Elf64_Shdr = elf.Elf64_Shdr; + +const std = @import("std"); +const elf = std.elf; +const Allocator = std.mem.Allocator; + +const bun = @import("bun"); diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 491e3b8438fa..cef3f482ddb0 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -188,4 +188,158 @@ describe("compiled binary validity", () => { }); }); +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); + + const { chmodSync } = require("fs"); + 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 converts PT_GNU_STACK to PT_LOAD. + 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); + + const { chmodSync } = require("fs"); + 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); + }); + }); +} + // file command test works well From a4024af9290b2dd3ad50e2cf3492bde89c8271a8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:59:45 +0000 Subject: [PATCH 2/8] [autofix.ci] apply automated fixes --- src/elf.zig | 10 +++++----- test/internal/ban-limits.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/elf.zig b/src/elf.zig index e30ea8356065..8006235ae66a 100644 --- a/src/elf.zig +++ b/src/elf.zig @@ -219,12 +219,12 @@ fn alignUp(value: u64, alignment: u64) u64 { return (value + mask) & ~mask; } -const Elf64_Ehdr = elf.Elf64_Ehdr; -const Elf64_Phdr = elf.Elf64_Phdr; -const Elf64_Shdr = elf.Elf64_Shdr; +const bun = @import("bun"); const std = @import("std"); -const elf = std.elf; const Allocator = std.mem.Allocator; -const bun = @import("bun"); +const elf = std.elf; +const Elf64_Ehdr = elf.Elf64_Ehdr; +const Elf64_Phdr = elf.Elf64_Phdr; +const Elf64_Shdr = elf.Elf64_Shdr; diff --git a/test/internal/ban-limits.json b/test/internal/ban-limits.json index 4219d0eb413f..b0fcbe776a1f 100644 --- a/test/internal/ban-limits.json +++ b/test/internal/ban-limits.json @@ -36,8 +36,8 @@ "std.enums.tagName(": 2, "std.fs.Dir": 164, "std.fs.File": 93, - "std.fs.cwd": 110, - "std.fs.openFileAbsolute": 10, + "std.fs.cwd": 109, + "std.fs.openFileAbsolute": 8, "std.log": 1, "std.mem.indexOfAny(u8": 0, "std.unicode": 27, From 6df1908619d60213edd0391d54b61de1e702bd7b Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Thu, 12 Feb 2026 04:14:48 +0000 Subject: [PATCH 3/8] fix(compile): guard BlobHeader behind OS(DARWIN) || __linux__ MSVC limits alignment to 8192 bytes. BlobHeader has 16KB alignment which is only needed on macOS and Linux (for the section-based standalone approach). Windows uses a different PE section strategy. Co-Authored-By: Claude Opus 4.6 --- src/bun.js/bindings/c-bindings.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bun.js/bindings/c-bindings.cpp b/src/bun.js/bindings/c-bindings.cpp index b7a9a148d647..eb58ff30aadd 100644 --- a/src/bun.js/bindings/c-bindings.cpp +++ b/src/bun.js/bindings/c-bindings.cpp @@ -912,6 +912,8 @@ extern "C" void Bun__signpost_emit(os_log_t log, os_signpost_type_t type, os_sig #endif // OS(DARWIN) signpost code +#if OS(DARWIN) || defined(__linux__) + #define BLOB_HEADER_ALIGNMENT 16 * 1024 extern "C" { @@ -930,7 +932,7 @@ extern "C" uint64_t* Bun__getStandaloneModuleGraphMachoLength() return &BUN_COMPILED.size; } -#elif defined(__linux__) +#else // __linux__ extern "C" BlobHeader __attribute__((section(".bun"), aligned(BLOB_HEADER_ALIGNMENT), used)) BUN_COMPILED = { 0 }; @@ -939,6 +941,8 @@ extern "C" uint64_t* Bun__getStandaloneModuleGraphELFVaddr() return &BUN_COMPILED.size; } +#endif // OS(DARWIN) / __linux__ + #elif defined(_WIN32) // Windows PE section handling #include From c10a786b2d63e64ec7eefa54442fd9275c04644c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 04:20:14 +0000 Subject: [PATCH 4/8] fix(test): use module-scope import for chmodSync instead of dynamic require Replace dynamic `require("fs")` inside test functions with a module-scope `import { chmodSync } from "node:fs"` per test/CLAUDE.md guidelines. https://claude.ai/code/session_01UujMs6n1JkfK5Sr18JM6du --- test/bundler/bun-build-compile.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index cef3f482ddb0..20655ead8f42 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { chmodSync } from "node:fs"; import { isArm64, isLinux, isMacOS, isMusl, isWindows, tempDir } from "harness"; import { join } from "path"; @@ -205,7 +206,6 @@ if (isLinux) { expect(result.success).toBe(true); - const { chmodSync } = require("fs"); chmodSync(result.outputs[0].path, 0o111); await using proc = Bun.spawn({ @@ -268,7 +268,6 @@ if (isLinux) { expect(result.success).toBe(true); - const { chmodSync } = require("fs"); chmodSync(result.outputs[0].path, 0o111); await using proc = Bun.spawn({ From 2726bf88ddffffc8750da01a334cb6fa035b1743 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 04:22:07 +0000 Subject: [PATCH 5/8] [autofix.ci] apply automated fixes --- test/bundler/bun-build-compile.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 20655ead8f42..bdfd1b6cf02f 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 { chmodSync } from "node:fs"; import { isArm64, isLinux, isMacOS, isMusl, isWindows, tempDir } from "harness"; +import { chmodSync } from "node:fs"; import { join } from "path"; describe("Bun.build compile", () => { From 52c68b8d4ca70056942fc14b3f95fc7b1dd6a787 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Thu, 12 Feb 2026 06:11:19 +0000 Subject: [PATCH 6/8] From 5268a5e546d436c0fec1e9553ea3fd42ddc6ee61 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 21 Feb 2026 14:21:58 -0800 Subject: [PATCH 7/8] nitpicks --- src/elf.zig | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/elf.zig b/src/elf.zig index 8006235ae66a..639f4b0e02fc 100644 --- a/src/elf.zig +++ b/src/elf.zig @@ -15,7 +15,7 @@ pub const ElfFile = struct { const ehdr = readEhdr(elf_data); // Validate ELF magic - if (!std.mem.eql(u8, ehdr.e_ident[0..4], "\x7fELF")) return error.InvalidElfFile; + if (!bun.strings.eqlComptime(ehdr.e_ident[0..4], "\x7fELF")) return error.InvalidElfFile; // Must be 64-bit if (ehdr.e_ident[elf.EI_CLASS] != elf.ELFCLASS64) return error.Not64Bit; @@ -24,6 +24,7 @@ pub const ElfFile = struct { if (ehdr.e_ident[elf.EI_DATA] != elf.ELFDATA2LSB) return error.NotLittleEndian; var data = try std.array_list.Managed(u8).initCapacity(allocator, elf_data.len); + errdefer data.deinit(); try data.appendSlice(elf_data); const self = try allocator.create(ElfFile); @@ -52,7 +53,8 @@ pub const ElfFile = struct { /// and expanding in-place would invalidate their absolute virtual addresses. pub fn writeBunSection(self: *ElfFile, payload: []const u8) !void { const ehdr = readEhdr(self.data.items); - const bun_section_offset = try self.findBunSection(ehdr); + const bun_section = try self.findBunSection(ehdr); + const bun_section_offset = bun_section.file_offset; const page_size = pageSize(ehdr); const header_size: u64 = @sizeOf(u64); @@ -120,6 +122,18 @@ pub const ElfFile = struct { // Non-standalone binaries have BUN_COMPILED.size = 0, so 0 means "no data". std.mem.writeInt(u64, self.data.items[bun_section_offset..][0..8], new_vaddr, .little); + // Update the .bun section header to reflect the new data location and size + // so that tools like `readelf -S` show accurate metadata. + { + const shdr_offset = new_shdr_offset + @as(u64, bun_section.section_index) * @sizeOf(Elf64_Shdr); + const shdr_bytes = self.data.items[shdr_offset..][0..@sizeOf(Elf64_Shdr)]; + var shdr = std.mem.bytesAsValue(Elf64_Shdr, shdr_bytes).*; + shdr.sh_offset = new_file_offset; + shdr.sh_size = new_content_size; + shdr.sh_addr = new_vaddr; + @memcpy(shdr_bytes, std.mem.asBytes(&shdr)); + } + // Find PT_GNU_STACK and convert it to PT_LOAD for the new .bun data. // PT_GNU_STACK only controls stack executability; on modern kernels the // stack defaults to non-executable without it, so repurposing is safe. @@ -157,8 +171,15 @@ pub const ElfFile = struct { // --- Internal helpers --- - /// Returns the file offset (sh_offset) of the `.bun` section. - fn findBunSection(self: *const ElfFile, ehdr: Elf64_Ehdr) !u64 { + const BunSectionInfo = struct { + /// File offset of the .bun section's data (sh_offset). + file_offset: u64, + /// Index of the .bun section in the section header table. + section_index: u16, + }; + + /// Returns the file offset and section index of the `.bun` section. + fn findBunSection(self: *const ElfFile, ehdr: Elf64_Ehdr) !BunSectionInfo { const shdr_size = @sizeOf(Elf64_Shdr); const shdr_table_offset = ehdr.e_shoff; const shnum = ehdr.e_shnum; @@ -182,8 +203,11 @@ pub const ElfFile = struct { if (name_offset < strtab.len) { const name = std.mem.sliceTo(strtab[name_offset..], 0); - if (std.mem.eql(u8, name, ".bun")) { - return shdr.sh_offset; + if (bun.strings.eqlComptime(name, ".bun")) { + return .{ + .file_offset = shdr.sh_offset, + .section_index = @intCast(i), + }; } } } From 77ca147d64ed8da6c97eb66f310a9ecd7bf89a41 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 19 Mar 2026 21:38:56 -0700 Subject: [PATCH 8/8] Faster --- test/bundler/bundler_compile.test.ts | 14 +++-- test/bundler/expectBundled.ts | 81 ++++++++++++++++++---------- 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index c7758f2343a4..18648abf29ed 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -3,9 +3,13 @@ import { describe, expect, test } from "bun:test"; import { rmSync } from "fs"; import { bunEnv, bunExe, isWindows, tempDir, tempDirWithFiles } from "harness"; import { join } from "path"; -import { itBundled } from "./expectBundled"; +import { BundlerTestInput, itBundled as itBundledBase } from "./expectBundled"; -describe("bundler", () => { +// Default to the CLI backend so tests in this describe.concurrent block +// actually interleave (the API backend uses process.chdir and is forced serial). +const itBundled = (id: string, opts: BundlerTestInput) => itBundledBase(id, { backend: "cli", ...opts }); + +describe.concurrent("bundler", () => { itBundled("compile/HelloWorld", { compile: true, files: { @@ -867,7 +871,7 @@ const server = serve({ .cwd(dir) .env(bunEnv) .throws(true); - }); + }, 30_000); // Verify ESM bytecode is actually loaded from the cache at runtime, not just generated. // Uses regex matching on stderr (not itBundled) since we don't know the exact @@ -915,7 +919,7 @@ const server = serve({ expect(exeStdout).toContain("esm bytecode loaded"); expect(exeStderr).toMatch(/\[Disk Cache\].*Cache hit/i); expect(exeExitCode).toBe(0); - }); + }, 30_000); // When compiling with 8+ entry points, the main entry point should still run correctly. test("compile with 8+ entry points runs main entry correctly", async () => { @@ -935,5 +939,5 @@ const server = serve({ const result = await Bun.$`./app`.cwd(dir).env(bunEnv).nothrow(); expect(result.stdout.toString().trim()).toBe("IT WORKS"); - }); + }, 30_000); }); diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index 3b19061183b2..6d15a8c12352 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -121,6 +121,24 @@ if (!existsSync(path.dirname(tempDirectoryTemplate))) const tempDirectory = mkdtempSync(tempDirectoryTemplate); const testsRan = new Set(); +const originalCwd = process.cwd(); + +function resolveBackend(opts: BundlerTestInput): "cli" | "api" { + if (opts.backend) return opts.backend; + const run = opts.run === true ? {} : opts.run; + const hasValidate = !Array.isArray(run) && run?.validate; + return opts.dotenv || + typeof opts.production !== "undefined" || + opts.bundling === false || + opts.emitDCEAnnotations || + opts.bundleWarnings || + opts.env || + hasValidate || + opts.define + ? "cli" + : "api"; +} + if (ESBUILD) { console.warn("NOTE: using esbuild for bun build tests"); } @@ -589,19 +607,7 @@ function expectBundled( } return (async () => { - if (!backend) { - backend = - dotenv || - typeof production !== "undefined" || - bundling === false || - emitDCEAnnotations || - bundleWarnings || - env || - run?.validate || - define - ? "cli" - : "api"; - } + backend ??= resolveBackend(opts); let root = path.join( tempDirectory, @@ -664,12 +670,14 @@ function expectBundled( } mkdirSync(root, { recursive: true }); if (install) { - const installProcess = Bun.spawnSync({ + const installProcess = Bun.spawn({ cmd: [bunExe(), "install", ...install, "--linker=hoisted"], cwd: root, + stdio: ["ignore", "inherit", "inherit"], }); - if (!installProcess.success) { - const reason = installProcess.signalCode || `code ${installProcess.exitCode}`; + const installExitCode = await installProcess.exited; + if (installExitCode !== 0) { + const reason = installProcess.signalCode || `code ${installExitCode}`; throw new Error(`Failed to install dependencies: ${reason}`); } } @@ -907,12 +915,20 @@ function expectBundled( } } - const { stdout, stderr, success, exitCode } = Bun.spawnSync({ + const buildProc = Bun.spawn({ cmd, cwd: root, stdio: ["ignore", "pipe", "pipe"], env: bundlerEnv, }); + const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([ + buildProc.stdout.bytes(), + buildProc.stderr.bytes(), + buildProc.exited, + ]); + const stdout = Buffer.from(stdoutBytes); + const stderr = Buffer.from(stderrBytes); + const success = exitCode === 0; // Check for errors if (!success) { @@ -1181,12 +1197,11 @@ for (const [key, blob] of build.outputs) { configRef = buildConfig; let build: BuildOutput; try { - const cwd = process.cwd(); process.chdir(root); try { build = await Bun.build(buildConfig); } finally { - process.chdir(cwd); + process.chdir(originalCwd); } } catch (e) { if (e instanceof AggregateError) { @@ -1647,7 +1662,7 @@ for (const [key, blob] of build.outputs) { ...(run.args ?? []), ] as [string, ...string[]]; - const { success, stdout, stderr, exitCode, signalCode } = Bun.spawnSync({ + const runProc = Bun.spawn({ cmd: args, env: { ...bunEnv, @@ -1656,8 +1671,17 @@ for (const [key, blob] of build.outputs) { IS_TEST_RUNNER: "1", }, stdio: ["ignore", "pipe", "pipe"], - cwd: run.setCwd ? root : undefined, + cwd: run.setCwd ? root : originalCwd, }); + const [runStdout, runStderr, exitCode] = await Promise.all([ + runProc.stdout.bytes(), + runProc.stderr.bytes(), + runProc.exited, + ]); + const stdout = Buffer.from(runStdout); + const stderr = Buffer.from(runStderr); + const signalCode = runProc.signalCode ?? undefined; + const success = exitCode === 0; if (signalCode === "SIGTRAP") { throw new Error(prefix + "Runtime failed\n" + stdout!.toUnixString() + "\n" + stderr!.toUnixString()); @@ -1805,20 +1829,19 @@ export function itBundled( if (opts.todo && !FILTER) { it.todo(id, () => expectBundled(id, opts as any)); } else { - it( + // backend=api uses process.chdir and a module-global configRef, so it + // cannot run concurrently with other tests. it.serial is a no-op outside + // of describe.concurrent / --concurrent. + const testFn = resolveBackend(opts) === "api" ? it.serial : it; + const baseTimeout = opts.snapshotSourceMap || opts.compile ? 30_000 : 5_000; + testFn( id, () => expectBundled(id, opts as any), - // sourcemap code is slow - isCI ? undefined : isDebug ? Infinity : (opts.snapshotSourceMap ? 30_000 : 5_000) * (opts.timeoutScale ?? 1), + isCI ? undefined : isDebug ? Infinity : baseTimeout * (opts.timeoutScale ?? 1), ); } return ref; } -itBundled.concurrent = (id: string, opts: BundlerTestInput) => { - const { it } = testForFile(currentFile ?? callerSourceOrigin()); - it.concurrent(id, () => expectBundled(id, opts as any)); - return testRef(id, opts); -}; itBundled.only = (id: string, opts: BundlerTestInput) => { const { it } = testForFile(currentFile ?? callerSourceOrigin());