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 {
310310 close,
311311};
312312
313pub const Side = enum {
314 request,
315 response,
316};
317
313318pub const Header = struct {
314319 name: []const u8,
315320 value: []const u8,
......@@ -333,6 +338,8 @@ pub const Reader = struct {
333338 trailers: []const u8 = &.{},
334339 body_err: ?BodyError = null,
335340 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
337344 pub const RemainingChunkLen = enum(u64) {
338345 head = 0,
......@@ -458,8 +465,17 @@ pub const Reader = struct {
458465 };
459466 return &reader.interface;
460467 } else {
461 reader.state = .body_none;
462 return reader.in;
468 return switch (reader.side) {
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 };
463479 }
464480 },
465481 }
......@@ -746,6 +762,9 @@ pub const BodyWriter = struct {
746762 http_protocol_output: *Writer,
747763 state: State,
748764 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
750769 pub const Error = Writer.Error;
751770
......@@ -824,8 +843,13 @@ pub const BodyWriter = struct {
824843 assert(len == 0); // Trips when end() called before all bytes written.
825844 w.state = .end;
826845 },
827 .none => {},
828846 .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;
829853 }
830854 }
831855
......@@ -861,26 +885,27 @@ pub const BodyWriter = struct {
861885 /// * `endUnflushed`
862886 /// * `end`
863887 pub fn endChunkedUnflushed(w: *BodyWriter, options: EndChunkedOptions) Error!void {
864 if (w.isEliding()) {
865 w.state = .end;
866 return;
867 }
868 const bw = w.http_protocol_output;
869 switch (w.state.chunk_len) {
870 0 => {},
871 1 => unreachable, // Wrote more data than specified in chunk header.
872 2 => try bw.writeAll("\r\n"),
873 else => unreachable, // An earlier write call indicated more data would follow.
874 }
875 try bw.writeAll("0\r\n");
876 for (options.trailers) |trailer| {
877 try bw.writeAll(trailer.name);
878 try bw.writeAll(": ");
879 try bw.writeAll(trailer.value);
888 if (!w.isEliding()) {
889 const bw = w.http_protocol_output;
890 switch (w.state.chunk_len) {
891 0 => {},
892 1 => unreachable, // Wrote more data than specified in chunk header.
893 2 => try bw.writeAll("\r\n"),
894 else => unreachable, // An earlier write call indicated more data would follow.
895 }
896 try bw.writeAll("0\r\n");
897 for (options.trailers) |trailer| {
898 try bw.writeAll(trailer.name);
899 try bw.writeAll(": ");
900 try bw.writeAll(trailer.value);
901 try bw.writeAll("\r\n");
902 }
880903 try bw.writeAll("\r\n");
881904 }
882 try bw.writeAll("\r\n");
883905 w.state = .end;
906 if (w.client_request) |request| {
907 request.payload_pending = false;
908 }
884909 }
885910
886911 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 {
731731 pub fn reader(response: *Response, transfer_buffer: []u8) *Reader {
732732 response.head.invalidateStrings();
733733 const req = response.request;
734 if (!req.method.responseHasBody()) return .ending;
734 if (req.method == .HEAD) return .ending;
735735 const head = &response.head;
736736 return req.reader.bodyReader(transfer_buffer, head.transfer_encoding, head.content_length);
737737 }
......@@ -807,11 +807,14 @@ pub const Request = struct {
807807 headers: Headers,
808808
809809 /// 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.
811811 response_content_length: ?u64 = null,
812812 /// 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.
814814 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
816819 /// These headers are kept including when following a redirect to a
817820 /// different domain.
......@@ -888,8 +891,9 @@ pub const Request = struct {
888891 connection.closing = connection.closing or switch (r.reader.state) {
889892 .ready => false,
890893 .received_head => c: {
891 if (r.method.requestHasBody()) break :c true;
892 if (!r.method.responseHasBody()) break :c false;
894 if (r.payload_pending) break :c true;
895 if (r.method == .HEAD) break :c false;
896
893897 const reader = r.reader.bodyReader(&.{}, r.response_transfer_encoding, r.response_content_length);
894898 _ = reader.discardRemaining() catch |err| switch (err) {
895899 error.ReadFailed => break :c true,
......@@ -912,7 +916,6 @@ pub const Request = struct {
912916 /// Sends but does not flush a complete request as only HTTP head, no body.
913917 pub fn sendBodilessUnflushed(r: *Request) Writer.Error!void {
914918 assert(r.transfer_encoding == .none);
915 assert(!r.method.requestHasBody());
916919 try sendHead(r);
917920 }
918921
......@@ -941,13 +944,14 @@ pub const Request = struct {
941944 /// See also:
942945 /// * `sendBody`
943946 pub fn sendBodyUnflushed(r: *Request, buffer: []u8) Writer.Error!http.BodyWriter {
944 assert(r.method.requestHasBody());
947 r.payload_pending = true;
945948 try sendHead(r);
946949 const http_protocol_output = r.connection.?.writer();
947950 return switch (r.transfer_encoding) {
948951 .chunked => .{
949952 .http_protocol_output = http_protocol_output,
950953 .state = .init_chunked,
954 .client_request = r,
951955 .writer = .{
952956 .buffer = buffer,
953957 .vtable = &.{
......@@ -959,6 +963,7 @@ pub const Request = struct {
959963 .content_length => |len| .{
960964 .http_protocol_output = http_protocol_output,
961965 .state = .{ .content_length = len },
966 .client_request = r,
962967 .writer = .{
963968 .buffer = buffer,
964969 .vtable = &.{
......@@ -970,6 +975,7 @@ pub const Request = struct {
970975 .none => .{
971976 .http_protocol_output = http_protocol_output,
972977 .state = .none,
978 .client_request = r,
973979 .writer = .{
974980 .buffer = buffer,
975981 .vtable = &.{
......@@ -1273,6 +1279,7 @@ pub const Request = struct {
12731279 // Populated when `http.Reader.bodyReader` is called.
12741280 .interface = undefined,
12751281 .max_head_len = r.client.read_buffer_size,
1282 .side = .response,
12761283 };
12771284 r.redirect_behavior.subtractOne();
12781285 }
......@@ -1744,6 +1751,7 @@ pub fn request(
17441751 // Populated when `http.Reader.bodyReader` is called.
17451752 .interface = undefined,
17461753 .max_head_len = client.read_buffer_size,
1754 .side = .response,
17471755 },
17481756 .keep_alive = options.keep_alive,
17491757 .method = method,
lib/std/http/Server.zig+4-3
......@@ -30,6 +30,7 @@ pub fn init(in: *Reader, out: *Writer) Server {
3030 // Populated when `http.Reader.bodyReader` is called.
3131 .interface = undefined,
3232 .max_head_len = in.buffer.len,
33 .side = .request,
3334 },
3435 .out = out,
3536 };
......@@ -253,6 +254,7 @@ pub const Request = struct {
253254 .state = .received_head,
254255 .interface = undefined,
255256 .max_head_len = 4096,
257 .side = .request,
256258 },
257259 .out = undefined,
258260 };
......@@ -592,7 +594,7 @@ pub const Request = struct {
592594 assert(request.server.reader.state == .received_head);
593595 assert(request.head.expect == null);
594596 request.head.invalidateStrings();
595 if (!request.head.method.requestHasBody()) return .ending;
597 if (request.head.method == .HEAD) return .ending;
596598 return request.server.reader.bodyReader(buffer, request.head.transfer_encoding, request.head.content_length);
597599 }
598600
......@@ -627,8 +629,7 @@ pub const Request = struct {
627629 const r = &request.server.reader;
628630 if (keep_alive and request.head.keep_alive) switch (r.state) {
629631 .received_head => {
630 if (request.head.method.requestHasBody()) {
631 assert(request.head.transfer_encoding != .none or request.head.content_length != null);
632 if (request.head.transfer_encoding != .none or request.head.content_length != null) {
632633 const reader_interface = request.readerExpectContinue(&.{}) catch return false;
633634 _ = reader_interface.discardRemaining() catch return false;
634635 assert(r.state == .ready);
lib/std/http/test.zig+137
......@@ -17,6 +17,7 @@ test "content length reader state update" {
1717 .interface = undefined,
1818 .state = .ready,
1919 .max_head_len = 1024,
20 .side = .request,
2021 };
2122
2223 _ = try reader.receiveHead();
......@@ -1123,6 +1124,27 @@ fn echoTests(client: *http.Client, port: u16) !void {
11231124 try expectEqual(.expectation_failed, response.head.status);
11241125 _ = try response.reader(&.{}).discardRemaining();
11251126 }
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 }
11261148}
11271149
11281150const TestServer = struct {
......@@ -1320,3 +1342,118 @@ test "boot failed connections from the pool" {
13201342 try expectError(error.HttpConnectionClosing, client.fetch(.{ .location = .{ .uri = uri } }));
13211343 }
13221344}
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}