fix(compile): seek to start of file before EXDEV cross-device copy (#26883)

## What does this PR do?

Fixes `bun build --compile` producing an all-zeros binary when the
output directory is on a different filesystem than the temp directory.
This is common in Docker containers, Gitea runners, and other
environments using overlayfs.

## Problem

When `inject()` finishes writing the modified executable to the temp
file, the file descriptor's offset is at EOF. If the subsequent
`renameat()` to the output path fails with `EXDEV` (cross-device — the
temp file and output dir are on different filesystems), the code falls
back to `copyFileZSlowWithHandle()`, which:

1. Calls `fallocate()` to pre-allocate the output file to the correct
size (filled with zeros)
2. Calls `bun.copyFile(in_handle, out_handle)` — but `in_handle`'s
offset is at EOF
3. `copy_file_range` / `sendfile` / `read` all use the current file
offset (EOF), read 0 bytes, and return immediately
4. Result: output file is the correct size but entirely zeros

This explains user reports of `bun build --compile
--target=bun-darwin-arm64` producing invalid binaries that `file`
identifies as "data" rather than a Mach-O executable.

## Fix

Seek the input fd to offset 0 in `copyFileZSlowWithHandle` before
calling `bun.copyFile`.

## How did you verify your code works?

- `bun bd` compiles successfully
- `bun bd test test/bundler/bun-build-compile.test.ts` — 6/6 pass
- Added tests that verify compiled binaries have valid executable
headers and produce correct output
- Manually verified cross-compilation: `bun build --compile
--target=bun-darwin-arm64` produces a valid Mach-O binary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
robobun
2026-02-10 22:32:31 -08:00
committed by GitHub
parent e29e830a25
commit ba6e84fecd
2 changed files with 73 additions and 0 deletions

View File

@@ -4092,6 +4092,12 @@ pub fn copyFileZSlowWithHandle(in_handle: bun.FileDescriptor, to_dir: bun.FileDe
_ = std.os.linux.fallocate(out_handle.cast(), 0, 0, @intCast(stat_.size));
}
// Seek input to beginning — the caller may have written to this fd,
// leaving the file offset at EOF. copy_file_range / sendfile / read
// all use the current offset when called with null offsets.
// Ignore errors: the fd may be non-seekable (e.g. a pipe).
_ = setFileOffset(in_handle, 0);
switch (bun.copyFile(in_handle, out_handle)) {
.err => |e| return .{ .err = e },
.result => {},

View File

@@ -121,4 +121,71 @@ describe("Bun.build compile", () => {
});
});
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);
});
});
// file command test works well