authorgravatar for loweg@bgdn.ccAmilia MacIntyre <loweg@bgdn.cc> 2026-07-21 15:11:55-04:00
committergravatar for alex@alexrp.comAlex Rønne Petersen <alex@alexrp.com> 2026-10-14 21:07:39+02:00
log15c1d3132b1edc61e33310e195b16e9a02bcacdd
tree169f0e98975c366147dba0b3a1bf2c660527dec4
parenteb4b3eb34f729b89be4085dbca32c00532f48666
signature Signed by SSH key SHA256:7B/LJ7bpR1eX8aCXSr4mtd5M45VMPKcx9zY8e95b5QM

std.http: improve tolerance of request-body edge cases


4 files changed, 201 insertions(+), 30 deletions(-)

lib/std/http.zig+45-20
...@@ -310,6 +310,11 @@ pub const Connection = enum {...@@ -310,6 +310,11 @@ pub const Connection = enum {
310 close,310 close,
311};311};
312312
313pub const Side = enum {
314 request,
315 response,
316};
317
313pub const Header = struct {318pub const Header = struct {
314 name: []const u8,319 name: []const u8,
315 value: []const u8,320 value: []const u8,
...@@ -333,6 +338,8 @@ pub const Reader = struct {...@@ -333,6 +338,8 @@ pub const Reader = struct {
333 trailers: []const u8 = &.{},338 trailers: []const u8 = &.{},
334 body_err: ?BodyError = null,339 body_err: ?BodyError = null,
335 max_head_len: usize,340 max_head_len: usize,
341 /// Whether this is a reader over a request, sent by a client; or a response, sent by a server.
342 side: Side,
336343
337 pub const RemainingChunkLen = enum(u64) {344 pub const RemainingChunkLen = enum(u64) {
338 head = 0,345 head = 0,
...@@ -458,8 +465,17 @@ pub const Reader = struct {...@@ -458,8 +465,17 @@ pub const Reader = struct {
458 };465 };
459 return &reader.interface;466 return &reader.interface;
460 } else {467 } else {
461 reader.state = .body_none;468 return switch (reader.side) {
462 return reader.in;469 .response => resp: {
470 reader.state = .body_none;
471 break :resp reader.in;
472 },
473 .request => req: {
474 // RFC7230: If a request has no Content-Length and no Transfer-Encoding, it has no message body.
475 reader.state = .ready;
476 break :req .ending;
477 },
478 };
463 }479 }
464 },480 },
465 }481 }
...@@ -746,6 +762,9 @@ pub const BodyWriter = struct {...@@ -746,6 +762,9 @@ pub const BodyWriter = struct {
746 http_protocol_output: *Writer,762 http_protocol_output: *Writer,
747 state: State,763 state: State,
748 writer: Writer,764 writer: Writer,
765 /// Associated `Client.Request`, if applicable, used to tell the request
766 /// that the body has been written and the connection is safe to reuse.
767 client_request: ?*Client.Request = null,
749768
750 pub const Error = Writer.Error;769 pub const Error = Writer.Error;
751770
...@@ -824,8 +843,13 @@ pub const BodyWriter = struct {...@@ -824,8 +843,13 @@ pub const BodyWriter = struct {
824 assert(len == 0); // Trips when end() called before all bytes written.843 assert(len == 0); // Trips when end() called before all bytes written.
825 w.state = .end;844 w.state = .end;
826 },845 },
827 .none => {},
828 .chunk_len => return endChunkedUnflushed(w, .{}),846 .chunk_len => return endChunkedUnflushed(w, .{}),
847 // Don't clear payload_pending to prevent an unframed request from
848 // returning to the pool
849 .none => return,
850 }
851 if (w.client_request) |request| {
852 request.payload_pending = false;
829 }853 }
830 }854 }
831855
...@@ -861,26 +885,27 @@ pub const BodyWriter = struct {...@@ -861,26 +885,27 @@ pub const BodyWriter = struct {
861 /// * `endUnflushed`885 /// * `endUnflushed`
862 /// * `end`886 /// * `end`
863 pub fn endChunkedUnflushed(w: *BodyWriter, options: EndChunkedOptions) Error!void {887 pub fn endChunkedUnflushed(w: *BodyWriter, options: EndChunkedOptions) Error!void {
864 if (w.isEliding()) {888 if (!w.isEliding()) {
865 w.state = .end;889 const bw = w.http_protocol_output;
866 return;890 switch (w.state.chunk_len) {
867 }891 0 => {},
868 const bw = w.http_protocol_output;892 1 => unreachable, // Wrote more data than specified in chunk header.
869 switch (w.state.chunk_len) {893 2 => try bw.writeAll("\r\n"),
870 0 => {},894 else => unreachable, // An earlier write call indicated more data would follow.
871 1 => unreachable, // Wrote more data than specified in chunk header.895 }
872 2 => try bw.writeAll("\r\n"),896 try bw.writeAll("0\r\n");
873 else => unreachable, // An earlier write call indicated more data would follow.897 for (options.trailers) |trailer| {
874 }898 try bw.writeAll(trailer.name);
875 try bw.writeAll("0\r\n");899 try bw.writeAll(": ");
876 for (options.trailers) |trailer| {900 try bw.writeAll(trailer.value);
877 try bw.writeAll(trailer.name);901 try bw.writeAll("\r\n");
878 try bw.writeAll(": ");902 }
879 try bw.writeAll(trailer.value);
880 try bw.writeAll("\r\n");903 try bw.writeAll("\r\n");
881 }904 }
882 try bw.writeAll("\r\n");
883 w.state = .end;905 w.state = .end;
906 if (w.client_request) |request| {
907 request.payload_pending = false;
908 }
884 }909 }
885910
886 pub fn contentLengthDrain(w: *Writer, data: []const []const u8, splat: usize) Error!usize {911 pub fn contentLengthDrain(w: *Writer, data: []const []const u8, splat: usize) Error!usize {
lib/std/http/Client.zig+15-7
...@@ -731,7 +731,7 @@ pub const Response = struct {...@@ -731,7 +731,7 @@ pub const Response = struct {
731 pub fn reader(response: *Response, transfer_buffer: []u8) *Reader {731 pub fn reader(response: *Response, transfer_buffer: []u8) *Reader {
732 response.head.invalidateStrings();732 response.head.invalidateStrings();
733 const req = response.request;733 const req = response.request;
734 if (!req.method.responseHasBody()) return .ending;734 if (req.method == .HEAD) return .ending;
735 const head = &response.head;735 const head = &response.head;
736 return req.reader.bodyReader(transfer_buffer, head.transfer_encoding, head.content_length);736 return req.reader.bodyReader(transfer_buffer, head.transfer_encoding, head.content_length);
737 }737 }
...@@ -807,11 +807,14 @@ pub const Request = struct {...@@ -807,11 +807,14 @@ pub const Request = struct {
807 headers: Headers,807 headers: Headers,
808808
809 /// Populated in `receiveHead`; used in `deinit` to determine whether to809 /// Populated in `receiveHead`; used in `deinit` to determine whether to
810 /// discard the body to reuse the connection.810 /// discard the response body to reuse the connection.
811 response_content_length: ?u64 = null,811 response_content_length: ?u64 = null,
812 /// Populated in `receiveHead`; used in `deinit` to determine whether to812 /// Populated in `receiveHead`; used in `deinit` to determine whether to
813 /// discard the body to reuse the connection.813 /// discard the response body to reuse the connection.
814 response_transfer_encoding: http.TransferEncoding = .none,814 response_transfer_encoding: http.TransferEncoding = .none,
815 /// Used in `deinit` to determine whether the payload has been sent, making
816 /// it safe to reuse the connection.
817 payload_pending: bool = false,
815818
816 /// These headers are kept including when following a redirect to a819 /// These headers are kept including when following a redirect to a
817 /// different domain.820 /// different domain.
...@@ -888,8 +891,9 @@ pub const Request = struct {...@@ -888,8 +891,9 @@ pub const Request = struct {
888 connection.closing = connection.closing or switch (r.reader.state) {891 connection.closing = connection.closing or switch (r.reader.state) {
889 .ready => false,892 .ready => false,
890 .received_head => c: {893 .received_head => c: {
891 if (r.method.requestHasBody()) break :c true;894 if (r.payload_pending) break :c true;
892 if (!r.method.responseHasBody()) break :c false;895 if (r.method == .HEAD) break :c false;
896
893 const reader = r.reader.bodyReader(&.{}, r.response_transfer_encoding, r.response_content_length);897 const reader = r.reader.bodyReader(&.{}, r.response_transfer_encoding, r.response_content_length);
894 _ = reader.discardRemaining() catch |err| switch (err) {898 _ = reader.discardRemaining() catch |err| switch (err) {
895 error.ReadFailed => break :c true,899 error.ReadFailed => break :c true,
...@@ -912,7 +916,6 @@ pub const Request = struct {...@@ -912,7 +916,6 @@ pub const Request = struct {
912 /// Sends but does not flush a complete request as only HTTP head, no body.916 /// Sends but does not flush a complete request as only HTTP head, no body.
913 pub fn sendBodilessUnflushed(r: *Request) Writer.Error!void {917 pub fn sendBodilessUnflushed(r: *Request) Writer.Error!void {
914 assert(r.transfer_encoding == .none);918 assert(r.transfer_encoding == .none);
915 assert(!r.method.requestHasBody());
916 try sendHead(r);919 try sendHead(r);
917 }920 }
918921
...@@ -941,13 +944,14 @@ pub const Request = struct {...@@ -941,13 +944,14 @@ pub const Request = struct {
941 /// See also:944 /// See also:
942 /// * `sendBody`945 /// * `sendBody`
943 pub fn sendBodyUnflushed(r: *Request, buffer: []u8) Writer.Error!http.BodyWriter {946 pub fn sendBodyUnflushed(r: *Request, buffer: []u8) Writer.Error!http.BodyWriter {
944 assert(r.method.requestHasBody());947 r.payload_pending = true;
945 try sendHead(r);948 try sendHead(r);
946 const http_protocol_output = r.connection.?.writer();949 const http_protocol_output = r.connection.?.writer();
947 return switch (r.transfer_encoding) {950 return switch (r.transfer_encoding) {
948 .chunked => .{951 .chunked => .{
949 .http_protocol_output = http_protocol_output,952 .http_protocol_output = http_protocol_output,
950 .state = .init_chunked,953 .state = .init_chunked,
954 .client_request = r,
951 .writer = .{955 .writer = .{
952 .buffer = buffer,956 .buffer = buffer,
953 .vtable = &.{957 .vtable = &.{
...@@ -959,6 +963,7 @@ pub const Request = struct {...@@ -959,6 +963,7 @@ pub const Request = struct {
959 .content_length => |len| .{963 .content_length => |len| .{
960 .http_protocol_output = http_protocol_output,964 .http_protocol_output = http_protocol_output,
961 .state = .{ .content_length = len },965 .state = .{ .content_length = len },
966 .client_request = r,
962 .writer = .{967 .writer = .{
963 .buffer = buffer,968 .buffer = buffer,
964 .vtable = &.{969 .vtable = &.{
...@@ -970,6 +975,7 @@ pub const Request = struct {...@@ -970,6 +975,7 @@ pub const Request = struct {
970 .none => .{975 .none => .{
971 .http_protocol_output = http_protocol_output,976 .http_protocol_output = http_protocol_output,
972 .state = .none,977 .state = .none,
978 .client_request = r,
973 .writer = .{979 .writer = .{
974 .buffer = buffer,980 .buffer = buffer,
975 .vtable = &.{981 .vtable = &.{
...@@ -1273,6 +1279,7 @@ pub const Request = struct {...@@ -1273,6 +1279,7 @@ pub const Request = struct {
1273 // Populated when `http.Reader.bodyReader` is called.1279 // Populated when `http.Reader.bodyReader` is called.
1274 .interface = undefined,1280 .interface = undefined,
1275 .max_head_len = r.client.read_buffer_size,1281 .max_head_len = r.client.read_buffer_size,
1282 .side = .response,
1276 };1283 };
1277 r.redirect_behavior.subtractOne();1284 r.redirect_behavior.subtractOne();
1278 }1285 }
...@@ -1744,6 +1751,7 @@ pub fn request(...@@ -1744,6 +1751,7 @@ pub fn request(
1744 // Populated when `http.Reader.bodyReader` is called.1751 // Populated when `http.Reader.bodyReader` is called.
1745 .interface = undefined,1752 .interface = undefined,
1746 .max_head_len = client.read_buffer_size,1753 .max_head_len = client.read_buffer_size,
1754 .side = .response,
1747 },1755 },
1748 .keep_alive = options.keep_alive,1756 .keep_alive = options.keep_alive,
1749 .method = method,1757 .method = method,
lib/std/http/Server.zig+4-3
...@@ -30,6 +30,7 @@ pub fn init(in: *Reader, out: *Writer) Server {...@@ -30,6 +30,7 @@ pub fn init(in: *Reader, out: *Writer) Server {
30 // Populated when `http.Reader.bodyReader` is called.30 // Populated when `http.Reader.bodyReader` is called.
31 .interface = undefined,31 .interface = undefined,
32 .max_head_len = in.buffer.len,32 .max_head_len = in.buffer.len,
33 .side = .request,
33 },34 },
34 .out = out,35 .out = out,
35 };36 };
...@@ -253,6 +254,7 @@ pub const Request = struct {...@@ -253,6 +254,7 @@ pub const Request = struct {
253 .state = .received_head,254 .state = .received_head,
254 .interface = undefined,255 .interface = undefined,
255 .max_head_len = 4096,256 .max_head_len = 4096,
257 .side = .request,
256 },258 },
257 .out = undefined,259 .out = undefined,
258 };260 };
...@@ -592,7 +594,7 @@ pub const Request = struct {...@@ -592,7 +594,7 @@ pub const Request = struct {
592 assert(request.server.reader.state == .received_head);594 assert(request.server.reader.state == .received_head);
593 assert(request.head.expect == null);595 assert(request.head.expect == null);
594 request.head.invalidateStrings();596 request.head.invalidateStrings();
595 if (!request.head.method.requestHasBody()) return .ending;597 if (request.head.method == .HEAD) return .ending;
596 return request.server.reader.bodyReader(buffer, request.head.transfer_encoding, request.head.content_length);598 return request.server.reader.bodyReader(buffer, request.head.transfer_encoding, request.head.content_length);
597 }599 }
598600
...@@ -627,8 +629,7 @@ pub const Request = struct {...@@ -627,8 +629,7 @@ pub const Request = struct {
627 const r = &request.server.reader;629 const r = &request.server.reader;
628 if (keep_alive and request.head.keep_alive) switch (r.state) {630 if (keep_alive and request.head.keep_alive) switch (r.state) {
629 .received_head => {631 .received_head => {
630 if (request.head.method.requestHasBody()) {632 if (request.head.transfer_encoding != .none or request.head.content_length != null) {
631 assert(request.head.transfer_encoding != .none or request.head.content_length != null);
632 const reader_interface = request.readerExpectContinue(&.{}) catch return false;633 const reader_interface = request.readerExpectContinue(&.{}) catch return false;
633 _ = reader_interface.discardRemaining() catch return false;634 _ = reader_interface.discardRemaining() catch return false;
634 assert(r.state == .ready);635 assert(r.state == .ready);
lib/std/http/test.zig+137
...@@ -17,6 +17,7 @@ test "content length reader state update" {...@@ -17,6 +17,7 @@ test "content length reader state update" {
17 .interface = undefined,17 .interface = undefined,
18 .state = .ready,18 .state = .ready,
19 .max_head_len = 1024,19 .max_head_len = 1024,
20 .side = .request,
20 };21 };
2122
22 _ = try reader.receiveHead();23 _ = try reader.receiveHead();
...@@ -1123,6 +1124,27 @@ fn echoTests(client: *http.Client, port: u16) !void {...@@ -1123,6 +1124,27 @@ fn echoTests(client: *http.Client, port: u16) !void {
1123 try expectEqual(.expectation_failed, response.head.status);1124 try expectEqual(.expectation_failed, response.head.status);
1124 _ = try response.reader(&.{}).discardRemaining();1125 _ = try response.reader(&.{}).discardRemaining();
1125 }1126 }
1127
1128 { // Send GET with body
1129 const location = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/echo-content#get-with-body", .{port});
1130 defer gpa.free(location);
1131
1132 var body: std.Io.Writer.Allocating = .init(gpa);
1133 defer body.deinit();
1134 try body.ensureUnusedCapacity(64);
1135
1136 const res = try client.fetch(.{
1137 .location = .{ .url = location },
1138 .method = .GET,
1139 .payload = "Hello, World!\n",
1140 .extra_headers = &.{
1141 .{ .name = "content-type", .value = "text/plain" },
1142 },
1143 .response_writer = &body.writer,
1144 });
1145 try expectEqual(.ok, res.status);
1146 try expectEqualStrings("Hello, World!\n", body.written());
1147 }
1126}1148}
11271149
1128const TestServer = struct {1150const TestServer = struct {
...@@ -1320,3 +1342,118 @@ test "boot failed connections from the pool" {...@@ -1320,3 +1342,118 @@ test "boot failed connections from the pool" {
1320 try expectError(error.HttpConnectionClosing, client.fetch(.{ .location = .{ .uri = uri } }));1342 try expectError(error.HttpConnectionClosing, client.fetch(.{ .location = .{ .uri = uri } }));
1321 }1343 }
1322}1344}
1345
1346test "send and discard or echo empty bodies" {
1347 if (builtin.cpu.arch.isPowerPC64() and builtin.mode != .debug) return error.SkipZigTest; // https://github.com/llvm/llvm-project/issues/194257
1348 if (builtin.os.tag == .openbsd) return error.SkipZigTest; // https://codeberg.org/ziglang/zig/issues/30806
1349
1350 const io = std.testing.io;
1351 const test_server = try createTestServer(io, struct {
1352 fn run(test_server: *TestServer) anyerror!void {
1353 const net_server = &test_server.net_server;
1354 var recv_buffer: [0x100]u8 = undefined;
1355 var send_buffer: [0x100]u8 = undefined;
1356
1357 accept: while (!test_server.shutting_down) {
1358 var stream = try net_server.accept(io);
1359 defer stream.close(io);
1360
1361 var connection_br = stream.reader(io, &recv_buffer);
1362 var connection_bw = stream.writer(io, &send_buffer);
1363 var http_server = http.Server.init(&connection_br.interface, &connection_bw.interface);
1364
1365 while (http_server.reader.state == .ready) {
1366 var request = http_server.receiveHead() catch |err| switch (err) {
1367 error.HttpConnectionClosing => continue :accept,
1368 else => |e| return e,
1369 };
1370 if (mem.eql(u8, request.head.target, "/discard")) {
1371 try request.respond("ignored", .{});
1372 } else if (mem.eql(u8, request.head.target, "/echo")) {
1373 const body = try (try request.readerExpectContinue(&.{})).allocRemaining(std.testing.allocator, .unlimited);
1374 try request.respond(body, .{});
1375 }
1376 }
1377 }
1378 }
1379 });
1380 defer test_server.destroy();
1381
1382 const gpa = std.testing.allocator;
1383 var client: http.Client = .{ .allocator = gpa, .io = io };
1384 defer client.deinit();
1385
1386 const port = test_server.port();
1387
1388 const location_discard = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/discard", .{port});
1389 defer gpa.free(location_discard);
1390 const uri_discard = try std.Uri.parse(location_discard);
1391
1392 const location_echo = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/echo", .{port});
1393 defer gpa.free(location_echo);
1394 const uri_echo = try std.Uri.parse(location_echo);
1395
1396 { // Echo POST with sendBodiless
1397 var response_buffer: [0x100]u8 = undefined;
1398
1399 var request = try client.request(.POST, uri_echo, .{});
1400 defer request.deinit();
1401 try request.sendBodiless();
1402 var response = try request.receiveHead(&response_buffer);
1403 const body = try response.reader(&.{}).allocRemaining(gpa, .unlimited);
1404 defer gpa.free(body);
1405
1406 try expectEqualStrings("", body);
1407 }
1408
1409 // connection has been kept alive
1410 try expect(client.http_proxy != null or client.connection_pool.free_len == 1);
1411
1412 { // Discard POST with sendBodiless
1413 var response_buffer: [0x100]u8 = undefined;
1414
1415 var request = try client.request(.POST, uri_discard, .{});
1416 defer request.deinit();
1417 try request.sendBodiless();
1418 var response = try request.receiveHead(&response_buffer);
1419 const body = try response.reader(&.{}).allocRemaining(gpa, .unlimited);
1420 defer gpa.free(body);
1421
1422 try expectEqualStrings("ignored", body);
1423 }
1424
1425 // connection has been kept alive
1426 try expect(client.http_proxy != null or client.connection_pool.free_len == 1);
1427
1428 { // Discard POST with empty fetch payload
1429 var body: std.Io.Writer.Allocating = .init(gpa);
1430 defer body.deinit();
1431
1432 _ = try client.fetch(.{
1433 .method = .POST,
1434 .location = .{ .uri = uri_discard },
1435 .response_writer = &body.writer,
1436 .payload = "",
1437 });
1438 try expectEqualStrings("ignored", body.written());
1439 }
1440
1441 // connection has been kept alive
1442 try expect(client.http_proxy != null or client.connection_pool.free_len == 1);
1443
1444 { // Echo POST with empty fetch payload
1445 var body: std.Io.Writer.Allocating = .init(gpa);
1446 defer body.deinit();
1447
1448 _ = try client.fetch(.{
1449 .method = .POST,
1450 .location = .{ .uri = uri_echo },
1451 .response_writer = &body.writer,
1452 .payload = "",
1453 });
1454 try expectEqualStrings("", body.written());
1455 }
1456
1457 // connection has been kept alive
1458 try expect(client.http_proxy != null or client.connection_pool.free_len == 1);
1459}