Compare commits

..

3 Commits

Author SHA1 Message Date
Claude Bot
113dd0496b Merge main into claude/fix-formdata-null-byte-truncation
Resolve conflicts in src/url.zig by adopting main's simpler approach
(direct []const u8 slice) over the PR's offset/length approach, since
both solve the same null-byte preservation issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-11 20:14:25 +00:00
Luke Parker
5296e991fe fix(node:fs): translate numeric open flags from Windows constants to internal representation (#27975)
### What does this PR do?

Fixes `fs.openSync` / `fs.promises.open` throwing `EINVAL` on Windows
when passed numeric flags from `fs.constants`.

`fs.constants` exports native MSVC values (e.g. `O_CREAT=0x100`) but
`FileSystemFlags.fromJS` stored them as-is into the internal `bun.O`
representation (where `O_CREAT=0o100`). Bits like `O_CREAT` were
silently dropped during the `bun.O → libuv` conversion, causing
`CreateFileW` to get `TRUNCATE_EXISTING` instead of `CREATE_ALWAYS`.

**Changes:**
- Add `libuv.O.toBunO()` (inverse of `fromBunO`) and call it in
`FileSystemFlags.fromJS` on Windows
- Add missing `DSYNC`/`SYNC`/`DIRECT` mappings to `fromBunO` (string
flags like `"rs+"` were also silently dropping these)
- Fix `UV_FS_O_FILEMAP` in `NodeConstantsModule.h` — was unconditionally
`0`, should be `0x20000000` on Windows (matching
`ProcessBindingConstants.cpp`)

Closes #27974, closes #12696

### How did you verify your code works?

- Regression test `test/regression/issue/27974.test.ts` (6 tests) and
unit tests in `test/js/node/fs/fs.test.ts` (5 tests) covering numeric
flag combinations and string/numeric equivalence
- All 11 pass on `bun-debug`, all fail on system bun 1.3.10

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
2026-03-11 12:53:51 -07:00
Claude Bot
40eb697cf8 fix(http): preserve binary data with null bytes in FormData multipart parsing
FormData multipart parsing was truncating binary file content at null
bytes for files 8 bytes or smaller. The root cause was using
Semver.String's inline storage optimization which scans for null bytes
to determine string length - appropriate for version strings but not
for binary data.

Replaced Field.value (Semver.String) with explicit value_off/value_len
fields that store offset and length directly, preserving binary data
with null bytes.

Fixes #26740

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:51:34 +00:00
8 changed files with 416 additions and 38 deletions

View File

@@ -546,27 +546,13 @@ pub fn spawnMaybeSync(
else
jsc_vm.eventLoop();
// Save the original event loop handle on THIS stack frame. The singleton's
// `original_event_loop_handle` field is not nesting-safe: if a queued JS
// callback runs during sync_loop.tickWithTimeout() and calls spawnSync again,
// the nested prepare() overwrites the singleton field with the already-
// overridden handle, and both cleanups then restore the wrong value. Saving
// on each caller's stack frame means LIFO defer order restores correctly.
const saved_event_loop_handle = if (comptime is_sync) jsc_vm.event_loop_handle else {};
const saved_event_loop_ptr = if (comptime is_sync) jsc_vm.event_loop else {};
if (comptime is_sync) {
jsc_vm.rareData().spawnSyncEventLoop(jsc_vm).prepare(jsc_vm);
}
defer {
if (comptime is_sync) {
// Call cleanup first for its other bookkeeping (Windows timer stop).
// Its handle restore may be wrong if nesting occurred — we overwrite it below.
jsc_vm.rareData().spawnSyncEventLoop(jsc_vm).cleanup(jsc_vm, saved_event_loop_ptr);
// Authoritative restore from OUR stack frame, not the (possibly corrupted) singleton.
jsc_vm.event_loop_handle = saved_event_loop_handle;
jsc_vm.event_loop = saved_event_loop_ptr;
jsc_vm.rareData().spawnSyncEventLoop(jsc_vm).cleanup(jsc_vm, jsc_vm.eventLoop());
}
}

View File

@@ -31,13 +31,6 @@ original_event_loop_handle: @FieldType(jsc.VirtualMachine, "event_loop_handle")
uv_timer: if (bun.Environment.isWindows) ?*bun.windows.libuv.Timer else void = if (bun.Environment.isWindows) null else {},
did_timeout: bool = false,
/// Reentrancy guard. spawnSync can be called recursively if a queued JS
/// callback (e.g. an async subprocess's completion handler) runs during
/// tickWithTimeout and itself calls spawnSync. Without this counter, the
/// nested prepare() would overwrite original_event_loop_handle with the
/// already-overridden value, and both cleanups would restore the wrong loop.
nesting_depth: u32 = 0,
/// Minimal handler for the isolated loop
const Handler = struct {
pub fn wakeup(loop: *uws.Loop) callconv(.c) void {
@@ -102,22 +95,12 @@ pub fn prepare(this: *SpawnSyncEventLoop, vm: *jsc.VirtualMachine) void {
this.did_timeout = false;
this.event_loop.virtual_machine = vm;
// Only save/override on the outermost call. Nested calls are no-ops
// because the isolated loop is already active.
defer this.nesting_depth += 1;
if (this.nesting_depth > 0) return;
this.original_event_loop_handle = vm.event_loop_handle;
vm.event_loop_handle = if (bun.Environment.isPosix) this.uws_loop else this.uws_loop.uv_loop;
}
/// Restore the original event loop handle after spawnSync completes
pub fn cleanup(this: *SpawnSyncEventLoop, vm: *jsc.VirtualMachine, prev_event_loop: *jsc.EventLoop) void {
// Only restore on the outermost call. Inner cleanups skip restoration
// so the outer spawnSync keeps running on the isolated loop.
this.nesting_depth -= 1;
if (this.nesting_depth > 0) return;
vm.event_loop_handle = this.original_event_loop_handle;
vm.event_loop = prev_event_loop;

View File

@@ -654,7 +654,11 @@ DEFINE_NATIVE_MODULE(NodeConstants)
#ifdef O_EXCL
put(Identifier::fromString(vm, "O_EXCL"_s), jsNumber(O_EXCL));
#endif
#if OS(WINDOWS)
put(Identifier::fromString(vm, "UV_FS_O_FILEMAP"_s), jsNumber(536870912));
#else
put(Identifier::fromString(vm, "UV_FS_O_FILEMAP"_s), jsNumber(0));
#endif
#ifdef O_NOCTTY
put(Identifier::fromString(vm, "O_NOCTTY"_s), jsNumber(O_NOCTTY));

View File

@@ -1053,7 +1053,15 @@ pub const FileSystemFlags = enum(c_int) {
return ctx.throwValue(ctx.ERR(.OUT_OF_RANGE, "The value of \"flags\" is out of range. It must be an integer. Received {d}", .{val.asNumber()}).toJS());
}
const number = try val.coerce(i32, ctx);
return @as(FileSystemFlags, @enumFromInt(@max(number, 0)));
const flags = @max(number, 0);
// On Windows, numeric flags from fs.constants (e.g. O_CREAT=0x100)
// use the platform's native MSVC/libuv values which differ from the
// internal bun.O representation. Convert them here so downstream
// code that operates on bun.O flags works correctly.
if (comptime bun.Environment.isWindows) {
return @as(FileSystemFlags, @enumFromInt(bun.windows.libuv.O.toBunO(flags)));
}
return @as(FileSystemFlags, @enumFromInt(flags));
}
const jsType = val.jsType();

View File

@@ -190,22 +190,67 @@ pub const O = struct {
pub const SYMLINK = UV_FS_O_SYMLINK;
pub const SYNC = UV_FS_O_SYNC;
/// Convert from internal bun.O flags to libuv/Windows flags.
///
/// Note: NONBLOCK, NOFOLLOW, DIRECTORY, NOATIME, NOCTTY, SYMLINK map to
/// 0 in libuv on Windows (see UV_FS_O_* constants below), so they are
/// included here for correctness but are effectively no-ops.
/// When adding new flag mappings, keep in sync with toBunO.
pub fn fromBunO(c_flags: i32) i32 {
var flags: i32 = 0;
if (c_flags & bun.O.NONBLOCK != 0) flags |= NONBLOCK;
if (c_flags & bun.O.CREAT != 0) flags |= CREAT;
if (c_flags & bun.O.NOFOLLOW != 0) flags |= NOFOLLOW;
if (c_flags & bun.O.WRONLY != 0) flags |= WRONLY;
if (c_flags & bun.O.RDONLY != 0) flags |= RDONLY;
if (c_flags & bun.O.RDWR != 0) flags |= RDWR;
if (c_flags & bun.O.CREAT != 0) flags |= CREAT;
if (c_flags & bun.O.EXCL != 0) flags |= EXCL;
if (c_flags & bun.O.TRUNC != 0) flags |= TRUNC;
if (c_flags & bun.O.APPEND != 0) flags |= APPEND;
if (c_flags & bun.O.EXCL != 0) flags |= EXCL;
if (c_flags & bun.O.NONBLOCK != 0) flags |= NONBLOCK;
// SYNC and DSYNC must be mutually exclusive for libuv on Windows.
// On Linux, bun.O.SYNC (0o4010000) is a superset of bun.O.DSYNC
// (0o10000), so checking SYNC first ensures we emit only UV_FS_O_SYNC
// when both bits are present. libuv's fs__open rejects having both set.
if (c_flags & bun.O.SYNC != 0) {
flags |= SYNC;
} else if (c_flags & bun.O.DSYNC != 0) {
flags |= DSYNC;
}
if (c_flags & bun.O.NOFOLLOW != 0) flags |= NOFOLLOW;
if (c_flags & bun.O.DIRECT != 0) flags |= DIRECT;
if (c_flags & FILEMAP != 0) flags |= FILEMAP;
return flags;
}
/// Convert from libuv/Windows MSVC O_ flags to internal bun.O flags.
/// This is the inverse of fromBunO and is needed because fs.constants
/// exposes the platform's native C values to JavaScript, but internally
/// Bun normalizes all flags to the bun.O (POSIX-like) representation.
///
/// Only maps flags that have non-zero libuv values on Windows.
/// NOFOLLOW, NONBLOCK, DIRECTORY, NOATIME, NOCTTY, SYMLINK are all 0
/// in libuv on Windows (no-ops) and cannot be recovered from a bitmask.
/// When adding new flag mappings, keep in sync with fromBunO.
pub fn toBunO(uv_flags: i32) i32 {
var flags: i32 = 0;
if (uv_flags & WRONLY != 0) flags |= bun.O.WRONLY;
if (uv_flags & RDWR != 0) flags |= bun.O.RDWR;
if (uv_flags & CREAT != 0) flags |= bun.O.CREAT;
if (uv_flags & EXCL != 0) flags |= bun.O.EXCL;
if (uv_flags & TRUNC != 0) flags |= bun.O.TRUNC;
if (uv_flags & APPEND != 0) flags |= bun.O.APPEND;
// SYNC takes priority over DSYNC (see fromBunO comment).
if (uv_flags & SYNC != 0) {
flags |= bun.O.SYNC;
} else if (uv_flags & DSYNC != 0) {
flags |= bun.O.DSYNC;
}
if (uv_flags & DIRECT != 0) flags |= bun.O.DIRECT;
if (uv_flags & FILEMAP != 0) flags |= FILEMAP;
return flags;
}
};
const _O_WRONLY = 0x0001;

View File

@@ -8,6 +8,7 @@ import {
isIntelMacOS,
isPosix,
isWindows,
tempDir,
tempDirWithFiles,
tmpdirSync,
} from "harness";
@@ -3680,3 +3681,151 @@ it("overflowing mode doesn't crash", () => {
}),
);
});
describe("numeric flags produce same result as string flags", () => {
it("numeric O_CREAT|O_TRUNC|O_WRONLY is equivalent to 'w'", () => {
const { O_CREAT, O_TRUNC, O_WRONLY } = constants;
const numericFlag = O_CREAT | O_TRUNC | O_WRONLY;
using dir = tempDir("numeric-flags", {});
const fileStr = join(String(dir), "string.txt");
const fileNum = join(String(dir), "numeric.txt");
const fd1 = openSync(fileStr, "w", 0o666);
writeSync(fd1, "hello");
closeSync(fd1);
const fd2 = openSync(fileNum, numericFlag, 0o666);
writeSync(fd2, "hello");
closeSync(fd2);
expect(readFileSync(fileNum, "utf8")).toBe(readFileSync(fileStr, "utf8"));
});
it("numeric O_CREAT|O_WRONLY|O_APPEND is equivalent to 'a'", () => {
const { O_APPEND, O_CREAT, O_WRONLY } = constants;
const numericFlag = O_CREAT | O_WRONLY | O_APPEND;
using dir = tempDir("numeric-flags", {});
const fileStr = join(String(dir), "string.txt");
const fileNum = join(String(dir), "numeric.txt");
const fd1 = openSync(fileStr, "a", 0o666);
writeSync(fd1, "first");
closeSync(fd1);
const fd1b = openSync(fileStr, "a", 0o666);
writeSync(fd1b, "second");
closeSync(fd1b);
const fd2 = openSync(fileNum, numericFlag, 0o666);
writeSync(fd2, "first");
closeSync(fd2);
const fd2b = openSync(fileNum, numericFlag, 0o666);
writeSync(fd2b, "second");
closeSync(fd2b);
expect(readFileSync(fileNum, "utf8")).toBe(readFileSync(fileStr, "utf8"));
expect(readFileSync(fileNum, "utf8")).toBe("firstsecond");
});
it("numeric O_CREAT|O_RDWR|O_TRUNC is equivalent to 'w+'", () => {
const { O_CREAT, O_RDWR, O_TRUNC } = constants;
const numericFlag = O_CREAT | O_RDWR | O_TRUNC;
using dir = tempDir("numeric-flags", {});
const file = join(String(dir), "readwrite.txt");
const fd = openSync(file, numericFlag, 0o666);
writeSync(fd, "read-write");
// Read back from the same fd to verify O_RDWR actually grants read access.
const buf = Buffer.alloc(10);
const bytesRead = readSync(fd, buf, 0, 10, 0);
closeSync(fd);
expect(buf.toString("utf8", 0, bytesRead)).toBe("read-write");
});
it("numeric O_RDONLY reads existing file", () => {
const { O_RDONLY } = constants;
using dir = tempDir("numeric-flags", {});
const file = join(String(dir), "readonly.txt");
writeFileSync(file, "existing content");
const fd = openSync(file, O_RDONLY);
const buf = Buffer.alloc(50);
const bytesRead = readSync(fd, buf);
closeSync(fd);
expect(buf.slice(0, bytesRead).toString("utf8")).toBe("existing content");
});
it("numeric O_CREAT|O_EXCL|O_RDWR fails on existing file", () => {
const { O_CREAT, O_EXCL, O_RDWR } = constants;
const numericFlag = O_CREAT | O_EXCL | O_RDWR;
using dir = tempDir("numeric-flags", {});
const file = join(String(dir), "excl.txt");
// First open should succeed (creates the file).
const fd = openSync(file, numericFlag, 0o666);
closeSync(fd);
// Second open with O_EXCL should fail (file already exists).
expect(() => openSync(file, numericFlag, 0o666)).toThrow();
});
});
describe("synchronous I/O string flags", () => {
it("'rs' opens existing file for reading", () => {
using dir = tempDir("sync-flags", {
"existing.txt": "sync content",
});
const fd = openSync(join(String(dir), "existing.txt"), "rs");
const buf = Buffer.alloc(20);
const bytesRead = readSync(fd, buf);
closeSync(fd);
expect(buf.slice(0, bytesRead).toString("utf8")).toBe("sync content");
});
it("'rs+' opens existing file for read-write", () => {
using dir = tempDir("sync-flags", {
"existing.txt": "original",
});
const file = join(String(dir), "existing.txt");
const fd = openSync(file, "rs+");
writeSync(fd, "replaced");
closeSync(fd);
expect(readFileSync(file, "utf8")).toBe("replaced");
});
it("'as' creates and appends to file", () => {
using dir = tempDir("sync-flags", {});
const file = join(String(dir), "appended.txt");
const fd = openSync(file, "as");
writeSync(fd, "sync-append");
closeSync(fd);
expect(readFileSync(file, "utf8")).toBe("sync-append");
});
it("'as+' creates and appends with read access", () => {
using dir = tempDir("sync-flags", {});
const file = join(String(dir), "appended-rw.txt");
const fd = openSync(file, "as+");
writeSync(fd, "hello");
const buf = Buffer.alloc(10);
const bytesRead = readSync(fd, buf, 0, 10, 0);
closeSync(fd);
expect(buf.toString("utf8", 0, bytesRead)).toBe("hello");
});
});

View File

@@ -0,0 +1,88 @@
import { expect, test } from "bun:test";
// Regression test for https://github.com/oven-sh/bun/issues/26740
// FormData multipart parsing was truncating binary file content at null bytes
// for files 8 bytes or smaller due to Semver.String inline storage optimization.
test("FormData preserves binary data with null bytes in small files", async () => {
const testCases = [
{ name: "8 bytes with null at position 3", data: [0x01, 0x02, 0x03, 0x00, 0x05, 0x06, 0x07, 0x08] },
{ name: "4 bytes with null at end", data: [0x1f, 0x8b, 0x08, 0x00] },
{ name: "1 byte null", data: [0x00] },
{ name: "all nulls (8 bytes)", data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] },
{ name: "7 bytes ending with null", data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00] },
{ name: "6 bytes starting with null", data: [0x00, 0x02, 0x03, 0x04, 0x05, 0x06] },
];
await using server = Bun.serve({
port: 0,
async fetch(req) {
const formData = await req.formData();
const file = formData.get("file") as File;
const bytes = new Uint8Array(await file.arrayBuffer());
return Response.json({
expectedSize: parseInt(req.headers.get("x-expected-size") || "0"),
actualSize: bytes.byteLength,
content: Array.from(bytes),
});
},
});
for (const tc of testCases) {
const content = new Uint8Array(tc.data);
const file = new File([content], "test.bin", { type: "application/octet-stream" });
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`http://localhost:${server.port}`, {
method: "POST",
body: formData,
headers: { "x-expected-size": String(tc.data.length) },
});
const result = (await res.json()) as { expectedSize: number; actualSize: number; content: number[] };
expect(result.actualSize).toBe(result.expectedSize);
expect(result.content).toEqual(tc.data);
}
});
test("FormData preserves binary data in larger files (> 8 bytes)", async () => {
// This should have worked before the fix, but let's verify it still works
const testCases = [
{ name: "16 bytes with nulls", data: Array.from({ length: 16 }, (_, i) => (i % 3 === 0 ? 0x00 : i)) },
{ name: "9 bytes with null at start", data: [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] },
];
await using server = Bun.serve({
port: 0,
async fetch(req) {
const formData = await req.formData();
const file = formData.get("file") as File;
const bytes = new Uint8Array(await file.arrayBuffer());
return Response.json({
expectedSize: parseInt(req.headers.get("x-expected-size") || "0"),
actualSize: bytes.byteLength,
content: Array.from(bytes),
});
},
});
for (const tc of testCases) {
const content = new Uint8Array(tc.data);
const file = new File([content], "test.bin", { type: "application/octet-stream" });
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`http://localhost:${server.port}`, {
method: "POST",
body: formData,
headers: { "x-expected-size": String(tc.data.length) },
});
const result = (await res.json()) as { expectedSize: number; actualSize: number; content: number[] };
expect(result.actualSize).toBe(result.expectedSize);
expect(result.content).toEqual(tc.data);
}
});

View File

@@ -0,0 +1,115 @@
import { expect, test } from "bun:test";
import { isWindows, tempDir } from "harness";
import { closeSync, constants, open as openCb, openSync, readFileSync, writeSync } from "node:fs";
import { open } from "node:fs/promises";
import { join } from "node:path";
test("fs.openSync with numeric O_CREAT | O_TRUNC | O_WRONLY flags", () => {
const { O_CREAT, O_TRUNC, O_WRONLY } = constants;
const flag = O_TRUNC | O_CREAT | O_WRONLY;
using dir = tempDir("issue-27974", {});
const file = join(String(dir), "test.txt");
const fd = openSync(file, flag, 0o666);
writeSync(fd, "hello world");
closeSync(fd);
expect(readFileSync(file, "utf8")).toBe("hello world");
});
test("fs.openSync with numeric O_CREAT | O_WRONLY flags (no O_TRUNC)", () => {
const { O_CREAT, O_WRONLY } = constants;
const flag = O_CREAT | O_WRONLY;
using dir = tempDir("issue-27974", {});
const file = join(String(dir), "test2.txt");
const fd = openSync(file, flag, 0o666);
writeSync(fd, "created");
closeSync(fd);
expect(readFileSync(file, "utf8")).toBe("created");
});
test.if(isWindows)("fs.openSync with UV_FS_O_FILEMAP | O_CREAT | O_TRUNC | O_WRONLY", () => {
const { O_CREAT, O_TRUNC, O_WRONLY, UV_FS_O_FILEMAP } = constants;
const flag = UV_FS_O_FILEMAP | O_TRUNC | O_CREAT | O_WRONLY;
using dir = tempDir("issue-27974", {});
const file = join(String(dir), "filemap.txt");
const fd = openSync(file, flag, 0o666);
writeSync(fd, "filemap content");
closeSync(fd);
expect(readFileSync(file, "utf8")).toBe("filemap content");
});
test("fs.openSync with numeric O_RDWR | O_CREAT | O_EXCL flags", () => {
const { O_CREAT, O_RDWR, O_EXCL } = constants;
const flag = O_CREAT | O_RDWR | O_EXCL;
using dir = tempDir("issue-27974", {});
const file = join(String(dir), "exclusive.txt");
const fd = openSync(file, flag, 0o666);
writeSync(fd, "exclusive");
closeSync(fd);
expect(readFileSync(file, "utf8")).toBe("exclusive");
// Opening again with O_EXCL should fail since file exists.
expect(() => openSync(file, flag, 0o666)).toThrow();
});
test("fs.promises.open with numeric O_CREAT | O_TRUNC | O_WRONLY flags", async () => {
const { O_CREAT, O_TRUNC, O_WRONLY } = constants;
const flag = O_TRUNC | O_CREAT | O_WRONLY;
using dir = tempDir("issue-27974", {});
const file = join(String(dir), "async.txt");
await using fh = await open(file, flag, 0o666);
await fh.write("async hello");
expect(readFileSync(file, "utf8")).toBe("async hello");
});
test("fs.open (callback) with numeric O_CREAT | O_TRUNC | O_WRONLY flags", async () => {
const { O_CREAT, O_TRUNC, O_WRONLY } = constants;
const flag = O_TRUNC | O_CREAT | O_WRONLY;
using dir = tempDir("issue-27974", {});
const file = join(String(dir), "callback.txt");
const { promise, resolve, reject } = Promise.withResolvers<number>();
openCb(file, flag, 0o666, (err, fd) => {
if (err) reject(err);
else resolve(fd);
});
const fd = await promise;
writeSync(fd, "callback hello");
closeSync(fd);
expect(readFileSync(file, "utf8")).toBe("callback hello");
});
test("fs.openSync with numeric O_APPEND | O_CREAT | O_WRONLY flags", () => {
const { O_APPEND, O_CREAT, O_WRONLY } = constants;
const flag = O_APPEND | O_CREAT | O_WRONLY;
using dir = tempDir("issue-27974", {});
const file = join(String(dir), "append.txt");
const fd1 = openSync(file, flag, 0o666);
writeSync(fd1, "first");
closeSync(fd1);
const fd2 = openSync(file, flag, 0o666);
writeSync(fd2, "second");
closeSync(fd2);
expect(readFileSync(file, "utf8")).toBe("firstsecond");
});