diff --git a/src/url.zig b/src/url.zig index 3c1a029df3..8f0e7f7bb0 100644 --- a/src/url.zig +++ b/src/url.zig @@ -976,12 +976,21 @@ pub const FormData = struct { } pub const Field = struct { - value: bun.Semver.String = .{}, + /// Offset into the input buffer for the value (binary-safe, no null-termination). + value_off: u32 = 0, + /// Length of the value (binary-safe, no null-termination). + value_len: u32 = 0, filename: bun.Semver.String = .{}, content_type: bun.Semver.String = .{}, is_file: bool = false, zero_count: u8 = 0, + /// Get the value slice from the buffer. + pub fn valueSlice(self: Field, buf: []const u8) []const u8 { + if (self.value_len == 0) return ""; + return buf[self.value_off..][0..self.value_len]; + } + pub const Entry = union(enum) { field: Field, list: bun.BabyList(Field), @@ -1088,7 +1097,7 @@ pub const FormData = struct { form: *jsc.DOMFormData, pub fn onEntry(wrap: *@This(), name: bun.Semver.String, field: Field, buf: []const u8) void { - const value_str = field.value.slice(buf); + const value_str = field.valueSlice(buf); var key = jsc.ZigString.initUTF8(name.slice(buf)); if (field.is_file) { @@ -1278,7 +1287,9 @@ pub const FormData = struct { if (strings.endsWithComptime(body, "\r\n")) { body = body[0 .. body.len - 2]; } - field.value = subslicer.sub(body).value(); + // Store offset and length directly to preserve binary data with null bytes + field.value_off = @truncate(@intFromPtr(body.ptr) - @intFromPtr(input.ptr)); + field.value_len = @truncate(body.len); field.filename = filename orelse .{}; field.is_file = is_file; diff --git a/test/regression/issue/26740.test.ts b/test/regression/issue/26740.test.ts new file mode 100644 index 0000000000..629b6533da --- /dev/null +++ b/test/regression/issue/26740.test.ts @@ -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); + } +});