lib/http/src/client/response.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const alloc_phase = @import("alloc_phase");
   3 const http = @import("../root.zig");
   4 const field = @import("../field/root.zig");
   5 const chunks = http.chunk;
   6 
   7 pub const default_header_count: usize = 100;
   8 pub const default_head_bytes: usize = 16 * 1024;
   9 pub const default_body_bytes: usize = 16 * 1024 * 1024;
  10 
  11 pub const Header = struct {
  12     name: []const u8,
  13     value: []const u8,
  14 };
  15 
  16 pub const Limits = struct {
  17     response_count: usize,
  18     header_count_per_response: usize,
  19     head_bytes_per_response: usize,
  20     body_bytes_per_response: usize,
  21 };
  22 
  23 pub const Capacity = struct {
  24     response_count: usize,
  25     header_count_per_response: usize,
  26     head_bytes_per_response: usize,
  27     body_bytes_per_response: usize,
  28     header_count: usize,
  29     header_bytes: usize,
  30     head_bytes: usize,
  31     body_bytes: usize,
  32     storage_bytes: usize,
  33 
  34     pub fn derive(limits: Limits) error{CapacityOverflow}!Capacity {
  35         const header_count = try alloc_phase.capacity.mul(
  36             usize,
  37             limits.response_count,
  38             limits.header_count_per_response,
  39         );
  40         const header_bytes = try alloc_phase.capacity.mul(
  41             usize,
  42             header_count,
  43             @sizeOf(Header),
  44         );
  45         const head_bytes = try alloc_phase.capacity.mul(
  46             usize,
  47             limits.response_count,
  48             limits.head_bytes_per_response,
  49         );
  50         const body_bytes = try alloc_phase.capacity.mul(
  51             usize,
  52             limits.response_count,
  53             limits.body_bytes_per_response,
  54         );
  55         const header_and_head_bytes = try alloc_phase.capacity.add(
  56             usize,
  57             header_bytes,
  58             head_bytes,
  59         );
  60         const storage_bytes = try alloc_phase.capacity.add(
  61             usize,
  62             header_and_head_bytes,
  63             body_bytes,
  64         );
  65         return .{
  66             .response_count = limits.response_count,
  67             .header_count_per_response = limits.header_count_per_response,
  68             .head_bytes_per_response = limits.head_bytes_per_response,
  69             .body_bytes_per_response = limits.body_bytes_per_response,
  70             .header_count = header_count,
  71             .header_bytes = header_bytes,
  72             .head_bytes = head_bytes,
  73             .body_bytes = body_bytes,
  74             .storage_bytes = storage_bytes,
  75         };
  76     }
  77 };
  78 
  79 pub const Scratch = struct {
  80     headers: []Header,
  81     head: []u8,
  82     body: []u8,
  83 };
  84 
  85 pub const Error = error{
  86     MalformedResponse,
  87     ReadFailed,
  88     ResponseHeaderCapacityExceeded,
  89     ResponseHeadCapacityExceeded,
  90     ResponseBodyCapacityExceeded,
  91 };
  92 
  93 pub const ClientResponse = struct {
  94     status: u16,
  95     headers: []const Header,
  96     body: []const u8,
  97     connection_reusable: bool,
  98 
  99     pub fn header(self: ClientResponse, name: []const u8) ?[]const u8 {
 100         for (self.headers) |item| {
 101             if (std.ascii.eqlIgnoreCase(item.name, name)) return item.value;
 102         }
 103         return null;
 104     }
 105 
 106     /// Parses one complete response out of `data` and copies its bytes into
 107     /// `scratch` . The returned headers and body are slices of `scratch` , so they
 108     /// stay valid until that scratch is parsed into again. A chunked body is
 109     /// decoded in place inside the body region, over the chunk framing already
 110     /// written there. Capacity is checked before any byte is copied, so a scratch
 111     /// too small for the headers, the head, or the body returns an error and
 112     /// publishes nothing. A caller can enlarge the scratch and call again.
 113     pub fn parse(scratch: Scratch, data: []const u8) Error!ClientResponse {
 114         return parseWithMethod(scratch, data, null);
 115     }
 116 
 117     pub fn parseForMethod(scratch: Scratch, data: []const u8, method: []const u8) Error!ClientResponse {
 118         return parseWithMethod(scratch, data, method);
 119     }
 120 };
 121 
 122 pub const Framing = union(enum) {
 123     none,
 124     tunnel,
 125     fixed: usize,
 126     chunked,
 127     close,
 128 };
 129 
 130 pub const VersionClass = enum {
 131     http_1_0,
 132     http_1_1_or_later,
 133 };
 134 
 135 pub const HeadSurvey = struct {
 136     version: VersionClass,
 137     status: u16,
 138     header_count: usize,
 139     framing: Framing,
 140 };
 141 
 142 const Survey = struct {
 143     head_length: usize,
 144     head: HeadSurvey,
 145     body_length: usize,
 146     chunk_capacity: ?chunks.Capacity,
 147 };
 148 
 149 fn parseWithMethod(scratch: Scratch, data: []const u8, method: ?[]const u8) Error!ClientResponse {
 150     const survey = try surveyResponse(data, method);
 151     try checkCapacity(scratch, survey);
 152 
 153     @memcpy(scratch.head[0..survey.head_length], data[0..survey.head_length]);
 154     const body_source = data[survey.head_length..][0..survey.body_length];
 155     @memcpy(scratch.body[0..survey.body_length], body_source);
 156     return materialize(
 157         scratch,
 158         survey.head_length,
 159         survey.body_length,
 160         survey.head,
 161         survey.chunk_capacity,
 162     );
 163 }
 164 
 165 pub fn read(scratch: Scratch, reader: *std.Io.Reader, method: []const u8) Error!ClientResponse {
 166     var head_length: usize = 0;
 167     var body_length: usize = 0;
 168     var head_survey: ?HeadSurvey = null;
 169 
 170     while (true) {
 171         const read_buffer = readAvailable(reader) catch {
 172             return error.ReadFailed;
 173         };
 174         const read_length = read_buffer.len;
 175         if (read_length == 0) {
 176             const head = head_survey orelse {
 177                 if (head_length == 0) return error.ReadFailed;
 178                 return error.MalformedResponse;
 179             };
 180             return switch (head.framing) {
 181                 .none, .tunnel => materialize(scratch, head_length, 0, head, null),
 182                 .fixed => |expected| if (body_length == expected)
 183                     materialize(scratch, head_length, body_length, head, null)
 184                 else
 185                     error.MalformedResponse,
 186                 .chunked => error.MalformedResponse,
 187                 .close => materialize(scratch, head_length, body_length, head, null),
 188             };
 189         }
 190         var toss_length = read_length;
 191         defer reader.toss(toss_length);
 192 
 193         var offset: usize = 0;
 194         if (head_survey == null) {
 195             while (offset < read_length) : (offset += 1) {
 196                 if (head_length == scratch.head.len) {
 197                     return error.ResponseHeadCapacityExceeded;
 198                 }
 199                 scratch.head[head_length] = read_buffer[offset];
 200                 head_length += 1;
 201                 if (!std.mem.endsWith(u8, scratch.head[0..head_length], "\r\n\r\n")) {
 202                     continue;
 203                 }
 204                 const head = try surveyHead(scratch.head[0..head_length], method);
 205                 if (head.header_count > scratch.headers.len) {
 206                     return error.ResponseHeaderCapacityExceeded;
 207                 }
 208                 switch (head.framing) {
 209                     .fixed => |expected| if (expected > scratch.body.len) {
 210                         return error.ResponseBodyCapacityExceeded;
 211                     },
 212                     else => {},
 213                 }
 214                 head_survey = head;
 215                 offset += 1;
 216                 if (head.framing == .none or head.framing == .tunnel) {
 217                     toss_length = offset;
 218                     return materialize(scratch, head_length, 0, head, null);
 219                 }
 220                 if (head.framing == .fixed and head.framing.fixed == 0) {
 221                     toss_length = offset;
 222                     return materialize(scratch, head_length, 0, head, null);
 223                 }
 224                 break;
 225             }
 226         }
 227 
 228         const head = head_survey orelse continue;
 229         const input = read_buffer[offset..read_length];
 230         switch (head.framing) {
 231             .none, .tunnel => return materialize(scratch, head_length, 0, head, null),
 232             .fixed => |expected| {
 233                 const take = @min(expected - body_length, input.len);
 234                 @memcpy(scratch.body[body_length..][0..take], input[0..take]);
 235                 body_length += take;
 236                 if (body_length == expected) {
 237                     toss_length = offset + take;
 238                     return materialize(scratch, head_length, body_length, head, null);
 239                 }
 240             },
 241             .chunked => {
 242                 const previous_body_length = body_length;
 243                 const room = scratch.body.len - body_length;
 244                 const take = @min(room, input.len);
 245                 @memcpy(scratch.body[body_length..][0..take], input[0..take]);
 246                 body_length += take;
 247                 switch (chunks.scanBody(scratch.body[0..body_length])) {
 248                     .complete => |consumed| {
 249                         std.debug.assert(consumed >= previous_body_length);
 250                         std.debug.assert(consumed <= body_length);
 251                         const consumed_input = consumed - previous_body_length;
 252                         std.debug.assert(consumed_input <= take);
 253                         toss_length = offset + consumed_input;
 254                         body_length = consumed;
 255                         return materialize(
 256                             scratch,
 257                             head_length,
 258                             body_length,
 259                             head,
 260                             null,
 261                         );
 262                     },
 263                     .malformed => return error.MalformedResponse,
 264                     .incomplete => {},
 265                 }
 266                 if (take != input.len) return error.ResponseBodyCapacityExceeded;
 267             },
 268             .close => {
 269                 if (input.len > scratch.body.len - body_length) {
 270                     return error.ResponseBodyCapacityExceeded;
 271                 }
 272                 @memcpy(scratch.body[body_length..][0..input.len], input);
 273                 body_length += input.len;
 274             },
 275         }
 276     }
 277 }
 278 
 279 fn surveyResponse(data: []const u8, method: ?[]const u8) Error!Survey {
 280     const header_end = findHeaderEnd(data) orelse return error.MalformedResponse;
 281     const head_length = header_end + 4;
 282     const head = try surveyHead(data[0..head_length], method);
 283     const body_data = data[head_length..];
 284     var chunk_capacity: ?chunks.Capacity = null;
 285     const body_length = switch (head.framing) {
 286         .none, .tunnel => 0,
 287         .fixed => |expected| blk: {
 288             if (expected > body_data.len) return error.MalformedResponse;
 289             break :blk expected;
 290         },
 291         .chunked => blk: {
 292             const capacity = chunks.survey(body_data, null) catch {
 293                 return error.MalformedResponse;
 294             };
 295             chunk_capacity = capacity;
 296             break :blk capacity.consumed;
 297         },
 298         .close => body_data.len,
 299     };
 300     return .{
 301         .head_length = head_length,
 302         .head = head,
 303         .body_length = body_length,
 304         .chunk_capacity = chunk_capacity,
 305     };
 306 }
 307 
 308 pub fn surveyHead(head: []const u8, method: ?[]const u8) Error!HeadSurvey {
 309     const parts = splitHead(head) orelse return error.MalformedResponse;
 310     const status_line = try parseStatusLine(parts.status_line);
 311     const fields = try surveyFields(parts.header_lines);
 312     return .{
 313         .version = status_line.version,
 314         .status = status_line.status,
 315         .header_count = fields.header_count,
 316         .framing = try decideFraming(
 317             status_line.version,
 318             method,
 319             status_line.status,
 320             fields,
 321         ),
 322     };
 323 }
 324 
 325 const HeadParts = struct {
 326     status_line: []const u8,
 327     header_lines: []const u8,
 328 };
 329 
 330 fn splitHead(head: []const u8) ?HeadParts {
 331     if (!std.mem.endsWith(u8, head, "\r\n\r\n")) return null;
 332     const header_end = head.len - 4;
 333     const first_line_end = std.mem.indexOf(
 334         u8,
 335         head[0 .. header_end + 2],
 336         "\r\n",
 337     ) orelse return null;
 338     if (first_line_end > header_end) return null;
 339     return .{
 340         .status_line = head[0..first_line_end],
 341         .header_lines = if (first_line_end == header_end)
 342             ""
 343         else
 344             head[first_line_end + 2 .. header_end],
 345     };
 346 }
 347 
 348 const FieldSurvey = struct {
 349     header_count: usize = 0,
 350     content_length: ?[]const u8 = null,
 351     transfer_encoding: ?[]const u8 = null,
 352     duplicate_content_length: bool = false,
 353     duplicate_transfer_encoding: bool = false,
 354 };
 355 
 356 fn surveyFields(header_lines: []const u8) Error!FieldSurvey {
 357     var survey = FieldSurvey{};
 358     var lines = std.mem.splitSequence(u8, header_lines, "\r\n");
 359     while (lines.next()) |line| {
 360         if (line.len == 0) continue;
 361         const parsed = field.parseLine(line) orelse return error.MalformedResponse;
 362         survey.header_count += 1;
 363         if (std.ascii.eqlIgnoreCase(parsed.name, "Content-Length")) {
 364             if (survey.content_length != null) {
 365                 survey.duplicate_content_length = true;
 366             } else {
 367                 survey.content_length = parsed.value;
 368             }
 369         }
 370         if (std.ascii.eqlIgnoreCase(parsed.name, "Transfer-Encoding")) {
 371             if (survey.transfer_encoding != null) {
 372                 survey.duplicate_transfer_encoding = true;
 373             } else {
 374                 survey.transfer_encoding = parsed.value;
 375             }
 376         }
 377     }
 378     return survey;
 379 }
 380 
 381 fn decideFraming(
 382     version: VersionClass,
 383     method: ?[]const u8,
 384     status: u16,
 385     fields: FieldSurvey,
 386 ) Error!Framing {
 387     if (version == .http_1_0 and fields.transfer_encoding != null) {
 388         return error.MalformedResponse;
 389     }
 390     if (method) |name| {
 391         if (std.mem.eql(u8, name, "HEAD")) return .none;
 392     }
 393     if (status >= 100 and status < 200) return .none;
 394     if (status == 204 or status == 304) return .none;
 395     if (method) |name| {
 396         if (std.mem.eql(u8, name, "CONNECT") and status >= 200 and status < 300) {
 397             return .tunnel;
 398         }
 399     }
 400     if (fields.duplicate_content_length or
 401         fields.duplicate_transfer_encoding or
 402         (fields.content_length != null and fields.transfer_encoding != null))
 403     {
 404         return error.MalformedResponse;
 405     }
 406     if (fields.transfer_encoding) |value| {
 407         const final_coding = field.parseTransferEncoding(value) orelse {
 408             return error.MalformedResponse;
 409         };
 410         return if (final_coding == .chunked) .chunked else .close;
 411     }
 412     if (fields.content_length) |value| {
 413         const body_length = field.parseContentLength(value) orelse {
 414             return error.MalformedResponse;
 415         };
 416         return .{ .fixed = body_length };
 417     }
 418     return .close;
 419 }
 420 
 421 fn checkCapacity(scratch: Scratch, survey: Survey) Error!void {
 422     if (survey.head.header_count > scratch.headers.len) {
 423         return error.ResponseHeaderCapacityExceeded;
 424     }
 425     if (survey.head_length > scratch.head.len) {
 426         return error.ResponseHeadCapacityExceeded;
 427     }
 428     if (survey.body_length > scratch.body.len) {
 429         return error.ResponseBodyCapacityExceeded;
 430     }
 431 }
 432 
 433 fn materialize(
 434     scratch: Scratch,
 435     head_length: usize,
 436     body_length: usize,
 437     survey: HeadSurvey,
 438     surveyed_chunks: ?chunks.Capacity,
 439 ) Error!ClientResponse {
 440     const headers = fillHeaders(
 441         scratch.headers,
 442         scratch.head[0..head_length],
 443         survey.header_count,
 444     );
 445     const body = switch (survey.framing) {
 446         .none, .tunnel => @as([]const u8, &.{}),
 447         .fixed => |expected| scratch.body[0..expected],
 448         .chunked => blk: {
 449             const capacity = surveyed_chunks orelse chunks.survey(
 450                 scratch.body[0..body_length],
 451                 null,
 452             ) catch return error.MalformedResponse;
 453             const decoded = chunks.decodeSurveyedInto(
 454                 scratch.body,
 455                 scratch.body[0..body_length],
 456                 capacity,
 457             ) catch return error.MalformedResponse;
 458             break :blk decoded.body;
 459         },
 460         .close => scratch.body[0..body_length],
 461     };
 462     return .{
 463         .status = survey.status,
 464         .headers = headers,
 465         .body = body,
 466         .connection_reusable = connectionReusable(
 467             survey,
 468             headers,
 469         ),
 470     };
 471 }
 472 
 473 fn connectionReusable(
 474     survey: HeadSurvey,
 475     headers: []const Header,
 476 ) bool {
 477     switch (survey.framing) {
 478         .close, .tunnel => return false,
 479         .none, .fixed, .chunked => {},
 480     }
 481     var keep_alive = false;
 482     for (headers) |header| {
 483         if (!std.ascii.eqlIgnoreCase(
 484             header.name,
 485             "Connection",
 486         )) continue;
 487         var tokens = std.mem.splitScalar(
 488             u8,
 489             header.value,
 490             ',',
 491         );
 492         while (tokens.next()) |raw| {
 493             const token = std.mem.trim(u8, raw, " \t");
 494             if (std.ascii.eqlIgnoreCase(token, "close")) {
 495                 return false;
 496             }
 497             if (std.ascii.eqlIgnoreCase(
 498                 token,
 499                 "keep-alive",
 500             )) {
 501                 keep_alive = true;
 502             }
 503         }
 504     }
 505     return switch (survey.version) {
 506         .http_1_1_or_later => true,
 507         .http_1_0 => keep_alive,
 508     };
 509 }
 510 
 511 pub fn fillHeaders(entries: []Header, head: []const u8, expected_count: usize) []const Header {
 512     const parts = splitHead(head).?;
 513     var count: usize = 0;
 514     var lines = std.mem.splitSequence(u8, parts.header_lines, "\r\n");
 515     while (lines.next()) |line| {
 516         if (line.len == 0) continue;
 517         const parsed = field.parseLine(line).?;
 518         entries[count] = .{
 519             .name = parsed.name,
 520             .value = parsed.value,
 521         };
 522         count += 1;
 523     }
 524     std.debug.assert(count == expected_count);
 525     return entries[0..count];
 526 }
 527 
 528 const StatusLine = struct {
 529     version: VersionClass,
 530     status: u16,
 531 };
 532 
 533 fn parseStatusLine(status_line: []const u8) Error!StatusLine {
 534     const first_space = std.mem.indexOfScalar(u8, status_line, ' ') orelse {
 535         return error.MalformedResponse;
 536     };
 537     const version = parseVersion(status_line[0..first_space]) orelse {
 538         return error.MalformedResponse;
 539     };
 540     const after_first = status_line[first_space + 1 ..];
 541     if (after_first.len < 4 or after_first[3] != ' ') {
 542         return error.MalformedResponse;
 543     }
 544     const status = parseStatusCode(after_first[0..3]) orelse
 545         return error.MalformedResponse;
 546     if (!field.validValue(after_first[4..])) return error.MalformedResponse;
 547     return .{ .version = version, .status = status };
 548 }
 549 
 550 fn parseStatusCode(text: []const u8) ?u16 {
 551     if (text.len != 3) return null;
 552     for (text) |byte| {
 553         if (byte < '0' or byte > '9') return null;
 554     }
 555     return @as(u16, text[0] - '0') * 100 +
 556         @as(u16, text[1] - '0') * 10 +
 557         @as(u16, text[2] - '0');
 558 }
 559 
 560 fn parseVersion(text: []const u8) ?VersionClass {
 561     if (text.len != "HTTP/1.0".len) return null;
 562     if (!std.mem.eql(u8, text[0..7], "HTTP/1.")) return null;
 563     if (text[7] < '0' or text[7] > '9') return null;
 564     return if (text[7] == '0') .http_1_0 else .http_1_1_or_later;
 565 }
 566 
 567 fn findHeaderEnd(data: []const u8) ?usize {
 568     return std.mem.indexOf(u8, data, "\r\n\r\n");
 569 }
 570 
 571 fn readAvailable(reader: *std.Io.Reader) ![]const u8 {
 572     return reader.peekGreedy(1) catch |err| switch (err) {
 573         error.EndOfStream => return &.{},
 574         else => return err,
 575     };
 576 }
 577 
 578 fn independentCapacity(limits: Limits) error{CapacityOverflow}!Capacity {
 579     const header_count = @as(u128, limits.response_count) * limits.header_count_per_response;
 580     const header_bytes = header_count * @sizeOf(Header);
 581     const head_bytes = @as(u128, limits.response_count) * limits.head_bytes_per_response;
 582     const body_bytes = @as(u128, limits.response_count) * limits.body_bytes_per_response;
 583     const storage_bytes = header_bytes + head_bytes + body_bytes;
 584     if (header_count > std.math.maxInt(usize) or
 585         header_bytes > std.math.maxInt(usize) or
 586         head_bytes > std.math.maxInt(usize) or
 587         body_bytes > std.math.maxInt(usize) or
 588         storage_bytes > std.math.maxInt(usize))
 589     {
 590         return error.CapacityOverflow;
 591     }
 592     return .{
 593         .response_count = limits.response_count,
 594         .header_count_per_response = limits.header_count_per_response,
 595         .head_bytes_per_response = limits.head_bytes_per_response,
 596         .body_bytes_per_response = limits.body_bytes_per_response,
 597         .header_count = @intCast(header_count),
 598         .header_bytes = @intCast(header_bytes),
 599         .head_bytes = @intCast(head_bytes),
 600         .body_bytes = @intCast(body_bytes),
 601         .storage_bytes = @intCast(storage_bytes),
 602     };
 603 }
 604 
 605 test "Client response capacity matches independent arithmetic" {
 606     comptime {
 607         @stardustClaim(
 608             @import("alloc_phase").capacity.witness(@import("./root.zig").ClientResponseStorage, "http_client_response_capacity"),
 609             null,
 610             null,
 611             null,
 612             null,
 613             null,
 614             null,
 615         );
 616     }
 617 
 618     for (0..9) |response_count| {
 619         for (0..9) |header_count_per_response| {
 620             for (0..9) |head_bytes_per_response| {
 621                 for (0..9) |body_bytes_per_response| {
 622                     const limits = Limits{
 623                         .response_count = response_count,
 624                         .header_count_per_response = header_count_per_response,
 625                         .head_bytes_per_response = head_bytes_per_response,
 626                         .body_bytes_per_response = body_bytes_per_response,
 627                     };
 628                     try std.testing.expectEqual(
 629                         try independentCapacity(limits),
 630                         try Capacity.derive(limits),
 631                     );
 632                 }
 633             }
 634         }
 635     }
 636     const maximum = std.math.maxInt(usize);
 637     try std.testing.expectError(error.CapacityOverflow, Capacity.derive(.{
 638         .response_count = 2,
 639         .header_count_per_response = maximum,
 640         .head_bytes_per_response = 0,
 641         .body_bytes_per_response = 0,
 642     }));
 643     try std.testing.expectError(error.CapacityOverflow, Capacity.derive(.{
 644         .response_count = 1,
 645         .header_count_per_response = maximum,
 646         .head_bytes_per_response = 0,
 647         .body_bytes_per_response = 0,
 648     }));
 649     try std.testing.expectError(error.CapacityOverflow, Capacity.derive(.{
 650         .response_count = maximum,
 651         .header_count_per_response = 0,
 652         .head_bytes_per_response = 2,
 653         .body_bytes_per_response = 0,
 654     }));
 655     try std.testing.expectError(error.CapacityOverflow, Capacity.derive(.{
 656         .response_count = maximum,
 657         .header_count_per_response = 0,
 658         .head_bytes_per_response = 0,
 659         .body_bytes_per_response = 2,
 660     }));
 661     try std.testing.expectError(error.CapacityOverflow, Capacity.derive(.{
 662         .response_count = 1,
 663         .header_count_per_response = 1,
 664         .head_bytes_per_response = maximum,
 665         .body_bytes_per_response = 0,
 666     }));
 667     try std.testing.expectError(error.CapacityOverflow, Capacity.derive(.{
 668         .response_count = 1,
 669         .header_count_per_response = 0,
 670         .head_bytes_per_response = 1,
 671         .body_bytes_per_response = maximum,
 672     }));
 673 }
 674 
 675 test "Client response capacity failures preserve result storage" {
 676     comptime {
 677         @stardustClaim(
 678             @import("alloc_phase").capacity.witness(@import("./root.zig").ClientResponseStorage, "http_client_response_atomic"),
 679             null,
 680             null,
 681             null,
 682             null,
 683             null,
 684             null,
 685         );
 686     }
 687 
 688     const two_headers = "HTTP/1.1 200 OK\r\nX-One: one\r\nX-Two: two\r\nContent-Length: 5\r\n\r\nHello";
 689     var headers = [_]Header{.{ .name = "sentinel", .value = "value" }};
 690     var head = @as([128]u8, @splat(0xA5));
 691     var body = @as([8]u8, @splat(0x5A));
 692 
 693     try std.testing.expectError(
 694         error.ResponseHeaderCapacityExceeded,
 695         ClientResponse.parse(.{ .headers = &headers, .head = &head, .body = &body }, two_headers),
 696     );
 697     try std.testing.expectEqualStrings("sentinel", headers[0].name);
 698     try std.testing.expectEqualSlices(u8, &(@as([128]u8, @splat(0xA5))), &head);
 699     try std.testing.expectEqualSlices(u8, &(@as([8]u8, @splat(0x5A))), &body);
 700 
 701     var enough_headers: [3]Header = undefined;
 702     var short_head = @as([8]u8, @splat(0xA5));
 703     try std.testing.expectError(
 704         error.ResponseHeadCapacityExceeded,
 705         ClientResponse.parse(.{ .headers = &enough_headers, .head = &short_head, .body = &body }, two_headers),
 706     );
 707     try std.testing.expectEqualSlices(u8, &(@as([8]u8, @splat(0xA5))), &short_head);
 708     try std.testing.expectEqualSlices(u8, &(@as([8]u8, @splat(0x5A))), &body);
 709 
 710     var short_body = @as([4]u8, @splat(0x5A));
 711     try std.testing.expectError(
 712         error.ResponseBodyCapacityExceeded,
 713         ClientResponse.parse(.{ .headers = &enough_headers, .head = &head, .body = &short_body }, two_headers),
 714     );
 715     try std.testing.expectEqualSlices(u8, &(@as([128]u8, @splat(0xA5))), &head);
 716     try std.testing.expectEqualSlices(u8, &(@as([4]u8, @splat(0x5A))), &short_body);
 717 }
 718 
 719 test "Client response parsing accepts exact capacities" {
 720     comptime {
 721         @stardustClaim(
 722             @import("alloc_phase").capacity.witness(@import("./root.zig").ClientResponseStorage, "http_client_response_boundary"),
 723             null,
 724             null,
 725             null,
 726             null,
 727             null,
 728             null,
 729         );
 730     }
 731 
 732     const head_text = "HTTP/1.1 200 OK\r\nX-One: one\r\nContent-Length: 5\r\n\r\n";
 733     const raw = head_text ++ "Hello";
 734     var headers: [2]Header = undefined;
 735     var head: [head_text.len]u8 = undefined;
 736     var body: [5]u8 = undefined;
 737     const parsed = try ClientResponse.parse(
 738         .{ .headers = &headers, .head = &head, .body = &body },
 739         raw,
 740     );
 741 
 742     try std.testing.expectEqual(@as(u16, 200), parsed.status);
 743     try std.testing.expectEqualStrings("one", parsed.header("x-one").?);
 744     try std.testing.expectEqualStrings("Hello", parsed.body);
 745 }
 746 
 747 test "Client response parsing preserves HTTP body semantics" {
 748     var headers: [4]Header = undefined;
 749     var head_storage: [256]u8 = undefined;
 750     var body: [128]u8 = undefined;
 751     const scratch = Scratch{
 752         .headers = &headers,
 753         .head = &head_storage,
 754         .body = &body,
 755     };
 756 
 757     const head = try ClientResponse.parseForMethod(
 758         scratch,
 759         "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello",
 760         "HEAD",
 761     );
 762     try std.testing.expectEqual(@as(usize, 0), head.body.len);
 763     const no_content = try ClientResponse.parse(
 764         scratch,
 765         "HTTP/1.1 204 No Content\r\nContent-Length: 5\r\n\r\nHello",
 766     );
 767     try std.testing.expectEqual(@as(usize, 0), no_content.body.len);
 768     const chunked = try ClientResponse.parse(
 769         scratch,
 770         "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, CHUNKED\r\n\r\n2\r\nHe\r\n3\r\nllo\r\n0\r\nDigest: value\r\n\r\n",
 771     );
 772     try std.testing.expectEqualStrings("Hello", chunked.body);
 773 }
 774 
 775 test "Client response head survey rejects malformed field syntax" {
 776     const malformed = [_][]const u8{
 777         "HTTP/1.1 200 OK\r\nBadHeader\r\n\r\n",
 778         "HTTP/1.1 200 OK\r\n: value\r\n\r\n",
 779         "HTTP/1.1 200 OK\r\nBad Header: value\r\n\r\n",
 780         "HTTP/1.1 200 OK\r\nContent-Length : 0\r\n\r\n",
 781         "HTTP/1.1 200 OK\r\nX-Test: bad\x00value\r\n\r\n",
 782         "HTTP/1.1 200 OK\r\nX-Test: bad\x0bvalue\r\n\r\n",
 783         "HTTP/1.1 200 OK\r\nX-Test: bad\x7fvalue\r\n\r\n",
 784     };
 785     for (malformed) |head| {
 786         try std.testing.expectError(
 787             error.MalformedResponse,
 788             surveyHead(head, "HEAD"),
 789         );
 790     }
 791 }
 792 
 793 test "Client response status line enforces exact grammar" {
 794     const empty_reason = try parseStatusLine("HTTP/1.0 000 ");
 795     try std.testing.expectEqual(VersionClass.http_1_0, empty_reason.version);
 796     try std.testing.expectEqual(@as(u16, 0), empty_reason.status);
 797 
 798     const later = try parseStatusLine("HTTP/1.9 999 \tVisible\x80");
 799     try std.testing.expectEqual(VersionClass.http_1_1_or_later, later.version);
 800     try std.testing.expectEqual(@as(u16, 999), later.status);
 801     try std.testing.expectEqual(
 802         @as(u16, 200),
 803         (try parseStatusLine("HTTP/1.1 200  OK")).status,
 804     );
 805 
 806     const malformed = [_][]const u8{
 807         "HTTP/1.1 +200 ",
 808         "HTTP/1.1 -00 ",
 809         "HTTP/1.1 2_00 ",
 810         "HTTP/1.1 20 ",
 811         "HTTP/1.1 2000 ",
 812         "HTTP/1.1 200",
 813         "HTTP/1.1 200\tReason",
 814         "HTTP/1.1\t200 ",
 815         "HTTP/1.1  200 ",
 816         "HTTP/1.1 200 OK\x00",
 817         "HTTP/1.1 200 OK\x7f",
 818         "HTTP/1.1 200 OK\r",
 819     };
 820     for (malformed) |status_line| {
 821         try std.testing.expectError(
 822             error.MalformedResponse,
 823             parseStatusLine(status_line),
 824         );
 825     }
 826 
 827     const code_template = "HTTP/1.1 200 ";
 828     var code_line: [code_template.len]u8 = undefined;
 829     for (9..12) |position| {
 830         for (0..256) |raw| {
 831             @memcpy(&code_line, code_template);
 832             const byte: u8 = @intCast(raw);
 833             code_line[position] = byte;
 834             if (byte >= '0' and byte <= '9') {
 835                 _ = try parseStatusLine(&code_line);
 836             } else {
 837                 try std.testing.expectError(
 838                     error.MalformedResponse,
 839                     parseStatusLine(&code_line),
 840                 );
 841             }
 842         }
 843     }
 844 
 845     for ([_]usize{ 8, 12 }) |position| {
 846         for (0..256) |raw| {
 847             @memcpy(&code_line, code_template);
 848             const byte: u8 = @intCast(raw);
 849             code_line[position] = byte;
 850             if (byte == ' ') {
 851                 _ = try parseStatusLine(&code_line);
 852             } else {
 853                 try std.testing.expectError(
 854                     error.MalformedResponse,
 855                     parseStatusLine(&code_line),
 856                 );
 857             }
 858         }
 859     }
 860 
 861     const reason_template = "HTTP/1.1 200 ABC";
 862     var reason_line: [reason_template.len]u8 = undefined;
 863     for (13..reason_template.len) |position| {
 864         for (0..256) |raw| {
 865             @memcpy(&reason_line, reason_template);
 866             const byte: u8 = @intCast(raw);
 867             reason_line[position] = byte;
 868             const allowed = byte == '\t' or
 869                 (byte >= 0x20 and byte != 0x7f);
 870             if (allowed) {
 871                 _ = try parseStatusLine(&reason_line);
 872             } else {
 873                 try std.testing.expectError(
 874                     error.MalformedResponse,
 875                     parseStatusLine(&reason_line),
 876                 );
 877             }
 878         }
 879     }
 880 
 881     @memcpy(&code_line, code_template);
 882     for (0..1000) |raw| {
 883         code_line[9] = @intCast('0' + raw / 100);
 884         code_line[10] = @intCast('0' + (raw / 10) % 10);
 885         code_line[11] = @intCast('0' + raw % 10);
 886         try std.testing.expectEqual(
 887             @as(u16, @intCast(raw)),
 888             (try parseStatusLine(&code_line)).status,
 889         );
 890     }
 891 }
 892 
 893 test "Client response rejects transfer encoding on HTTP 1.0" {
 894     const faulty = [_]struct {
 895         head: []const u8,
 896         method: []const u8,
 897     }{
 898         .{
 899             .head = "HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n",
 900             .method = "GET",
 901         },
 902         .{
 903             .head = "HTTP/1.0 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n",
 904             .method = "HEAD",
 905         },
 906         .{
 907             .head = "HTTP/1.0 204 No Content\r\nTransfer-Encoding: gzip\r\n\r\n",
 908             .method = "GET",
 909         },
 910         .{
 911             .head = "HTTP/1.0 200 Connected\r\nTransfer-Encoding: , ,\r\n\r\n",
 912             .method = "CONNECT",
 913         },
 914     };
 915     for (faulty) |case| {
 916         try std.testing.expectError(
 917             error.MalformedResponse,
 918             surveyHead(case.head, case.method),
 919         );
 920     }
 921 
 922     const fixed = try surveyHead(
 923         "HTTP/1.0 200 OK\r\nContent-Length: 3\r\n\r\n",
 924         "GET",
 925     );
 926     try std.testing.expectEqual(VersionClass.http_1_0, fixed.version);
 927     try std.testing.expect(fixed.framing == .fixed);
 928 
 929     const later = try surveyHead(
 930         "HTTP/1.9 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n",
 931         "GET",
 932     );
 933     try std.testing.expectEqual(VersionClass.http_1_1_or_later, later.version);
 934     try std.testing.expect(later.framing == .chunked);
 935 }
 936 
 937 test "Client response head survey applies ordered framing" {
 938     const nonfinal = try surveyHead(
 939         "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked,\tGZIP \r\n\r\n",
 940         "GET",
 941     );
 942     try std.testing.expect(nonfinal.framing == .close);
 943 
 944     const final = try surveyHead(
 945         "HTTP/1.1 200 OK\r\ntRaNsFeR-EnCoDiNg:\t , tiny-coding; level=5, , CHUNKED, \t\r\n\r\n",
 946         "GET",
 947     );
 948     try std.testing.expect(final.framing == .chunked);
 949 
 950     const other = try surveyHead(
 951         "HTTP/1.1 200 OK\r\nTransfer-Encoding: deflate\r\n\r\n",
 952         "GET",
 953     );
 954     try std.testing.expect(other.framing == .close);
 955 
 956     const empty = try surveyHead(
 957         "HTTP/1.1 200 OK\r\nTransfer-Encoding: , ,\r\n\r\n",
 958         "GET",
 959     );
 960     try std.testing.expect(empty.framing == .close);
 961 
 962     const fixed = try surveyHead(
 963         "HTTP/1.1 205 Reset Content\r\nContent-Length: 3\r\n\r\n",
 964         "GET",
 965     );
 966     switch (fixed.framing) {
 967         .fixed => |length| try std.testing.expectEqual(@as(usize, 3), length),
 968         else => return error.TestUnexpectedFraming,
 969     }
 970 
 971     const fixed_zero = try surveyHead(
 972         "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n",
 973         "GET",
 974     );
 975     switch (fixed_zero.framing) {
 976         .fixed => |length| try std.testing.expectEqual(@as(usize, 0), length),
 977         else => return error.TestUnexpectedFraming,
 978     }
 979 
 980     const unframed = try surveyHead(
 981         "HTTP/1.1 305 Use Proxy\r\nX-Test: value\r\n\r\n",
 982         "GET",
 983     );
 984     try std.testing.expect(unframed.framing == .close);
 985 }
 986 
 987 test "Client response head survey rejects ordinary framing ambiguity" {
 988     const malformed = [_][]const u8{
 989         "HTTP/1.1 200 OK\r\nContent-Length: 3\r\nContent-Length: 3\r\n\r\n",
 990         "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n\r\n",
 991         "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\nContent-Length: 3\r\n\r\n",
 992         "HTTP/1.1 200 OK\r\nTransfer-Encoding: tiny-coding; name=\r\n\r\n",
 993         "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked; name=value\r\n\r\n",
 994         "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip; name=value\r\n\r\n",
 995         "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked, chunked\r\n\r\n",
 996         "HTTP/1.1 200 OK\r\nContent-Length: invalid\r\n\r\n",
 997         "HTTP/1.1 200 OK\r\nContent-Length: +3\r\n\r\n",
 998         "HTTP/1.1 200 OK\r\nContent-Length: -0\r\n\r\n",
 999         "HTTP/1.1 200 OK\r\nContent-Length: 1_0\r\n\r\n",
1000         "HTTP/1.1 200 OK\r\nContent-Length: 184467440737095516160\r\n\r\n",
1001     };
1002     for (malformed) |head| {
1003         try std.testing.expectError(
1004             error.MalformedResponse,
1005             surveyHead(head, "GET"),
1006         );
1007     }
1008 }
1009 
1010 test "Client response method and status rules precede framing headers" {
1011     const ambiguous = "Transfer-Encoding: gzip\r\nContent-Length: 3\r\n";
1012     const head = try surveyHead(
1013         "HTTP/1.1 200 OK\r\n" ++ ambiguous ++ "\r\n",
1014         "HEAD",
1015     );
1016     try std.testing.expect(head.framing == .none);
1017 
1018     const informational = try surveyHead(
1019         "HTTP/1.1 199 Informational\r\n" ++ ambiguous ++ "\r\n",
1020         "GET",
1021     );
1022     try std.testing.expect(informational.framing == .none);
1023 
1024     const no_content = try surveyHead(
1025         "HTTP/1.1 204 No Content\r\n" ++ ambiguous ++ "\r\n",
1026         "GET",
1027     );
1028     try std.testing.expect(no_content.framing == .none);
1029 
1030     const connect_no_content = try surveyHead(
1031         "HTTP/1.1 204 No Content\r\n" ++ ambiguous ++ "\r\n",
1032         "CONNECT",
1033     );
1034     try std.testing.expect(connect_no_content.framing == .none);
1035 
1036     const not_modified = try surveyHead(
1037         "HTTP/1.1 304 Not Modified\r\n" ++ ambiguous ++ "\r\n",
1038         "GET",
1039     );
1040     try std.testing.expect(not_modified.framing == .none);
1041 
1042     const tunnel = try surveyHead(
1043         "HTTP/1.1 299 Connected\r\n" ++ ambiguous ++ "\r\n",
1044         "CONNECT",
1045     );
1046     try std.testing.expect(tunnel.framing == .tunnel);
1047 
1048     try std.testing.expectError(
1049         error.MalformedResponse,
1050         surveyHead(
1051             "HTTP/1.1 305 Proxy\r\n" ++ ambiguous ++ "\r\n",
1052             "CONNECT",
1053         ),
1054     );
1055 
1056     const extension = try surveyHead(
1057         "HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\n",
1058         "head",
1059     );
1060     try std.testing.expect(extension.framing == .fixed);
1061 }
1062 
1063 test "Client response parsing retains nonfinal transfer coding semantics" {
1064     var headers: [2]Header = undefined;
1065     var head_storage: [128]u8 = undefined;
1066     var body: [32]u8 = undefined;
1067     const scratch = Scratch{
1068         .headers = &headers,
1069         .head = &head_storage,
1070         .body = &body,
1071     };
1072 
1073     const below_informational = try ClientResponse.parseForMethod(
1074         scratch,
1075         "HTTP/1.1 099 Status\r\n\r\nraw",
1076         "GET",
1077     );
1078     try std.testing.expectEqual(@as(u16, 99), below_informational.status);
1079     try std.testing.expectEqualStrings("raw", below_informational.body);
1080 
1081     const close = try ClientResponse.parseForMethod(
1082         scratch,
1083         "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked, tiny-coding; name=\"a,b\"\r\n\r\nraw",
1084         "GET",
1085     );
1086     try std.testing.expectEqualStrings("raw", close.body);
1087 
1088     const tunnel = try ClientResponse.parseForMethod(
1089         scratch,
1090         "HTTP/1.1 200 Connected\r\nContent-Length: 3\r\n\r\nraw",
1091         "CONNECT",
1092     );
1093     try std.testing.expectEqual(@as(usize, 0), tunnel.body.len);
1094 
1095     try std.testing.expectError(
1096         error.MalformedResponse,
1097         ClientResponse.parseForMethod(
1098             scratch,
1099             "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\nContent-Length: 3\r\n\r\nraw",
1100             "GET",
1101         ),
1102     );
1103 }
1104 
1105 test "Client response semantic failures preserve result storage" {
1106     const ambiguous = "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\nContent-Length: 3\r\n\r\nraw";
1107     var headers = [_]Header{.{ .name = "sentinel", .value = "value" }};
1108     var head = @as([128]u8, @splat(0xA5));
1109     var body = @as([8]u8, @splat(0x5A));
1110 
1111     try std.testing.expectError(
1112         error.MalformedResponse,
1113         ClientResponse.parse(
1114             .{ .headers = &headers, .head = &head, .body = &body },
1115             ambiguous,
1116         ),
1117     );
1118     try std.testing.expectEqualStrings("sentinel", headers[0].name);
1119     try std.testing.expectEqualSlices(u8, &(@as([128]u8, @splat(0xA5))), &head);
1120     try std.testing.expectEqualSlices(u8, &(@as([8]u8, @splat(0x5A))), &body);
1121 }
1122 
1123 test "Client response read materializes each HTTP body framing" {
1124     var headers: [4]Header = undefined;
1125     var head_storage: [256]u8 = undefined;
1126     var body: [128]u8 = undefined;
1127     const scratch = Scratch{
1128         .headers = &headers,
1129         .head = &head_storage,
1130         .body = &body,
1131     };
1132 
1133     var fixed_reader = std.Io.Reader.fixed(
1134         "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHelloTAIL",
1135     );
1136     const fixed = try read(scratch, &fixed_reader, "GET");
1137     try std.testing.expectEqualStrings("Hello", fixed.body);
1138     try std.testing.expectEqualStrings("TAIL", try fixed_reader.peekGreedy(1));
1139 
1140     var chunked_reader = std.Io.Reader.fixed(
1141         "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nHe\r\n3\r\nllo\r\n0\r\nDigest: value\r\n\r\nTAIL",
1142     );
1143     const chunked = try read(scratch, &chunked_reader, "GET");
1144     try std.testing.expectEqualStrings("Hello", chunked.body);
1145     try std.testing.expectEqualStrings("TAIL", try chunked_reader.peekGreedy(1));
1146 
1147     var close_reader = std.Io.Reader.fixed(
1148         "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello",
1149     );
1150     const close = try read(scratch, &close_reader, "GET");
1151     try std.testing.expectEqualStrings("Hello", close.body);
1152 
1153     var nonfinal_reader = std.Io.Reader.fixed(
1154         "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked, tiny-coding; name=\"a,b\"\r\n\r\nraw",
1155     );
1156     const nonfinal = try read(scratch, &nonfinal_reader, "GET");
1157     try std.testing.expectEqualStrings("raw", nonfinal.body);
1158 
1159     var ambiguous_reader = std.Io.Reader.fixed(
1160         "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\nContent-Length: 3\r\n\r\nraw",
1161     );
1162     try std.testing.expectError(
1163         error.MalformedResponse,
1164         read(scratch, &ambiguous_reader, "GET"),
1165     );
1166 
1167     var tunnel_reader = std.Io.Reader.fixed(
1168         "HTTP/1.1 200 Connected\r\nContent-Length: 5\r\n\r\nHello",
1169     );
1170     const tunnel = try read(scratch, &tunnel_reader, "CONNECT");
1171     try std.testing.expectEqual(@as(usize, 0), tunnel.body.len);
1172     try std.testing.expectEqualStrings(
1173         "Hello",
1174         try tunnel_reader.peekGreedy(1),
1175     );
1176 }
1177 
1178 test "Client response read preserves framed tails across fragmentation" {
1179     const cases = [_]struct {
1180         wire: []const u8,
1181         body: []const u8,
1182     }{
1183         .{
1184             .wire = "HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabcTAIL",
1185             .body = "abc",
1186         },
1187         .{
1188             .wire = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\nTAIL",
1189             .body = "abc",
1190         },
1191     };
1192 
1193     var headers: [2]Header = undefined;
1194     var head_storage: [128]u8 = undefined;
1195     var body_storage: [64]u8 = undefined;
1196     const scratch = Scratch{
1197         .headers = &headers,
1198         .head = &head_storage,
1199         .body = &body_storage,
1200     };
1201 
1202     for (cases) |case| {
1203         for (1..9) |fragment_length| {
1204             var read_buffer: [32]u8 = undefined;
1205             var source = std.testing.Reader.init(
1206                 &read_buffer,
1207                 &.{.{ .buffer = case.wire }},
1208             );
1209             source.artificial_limit = .limited(fragment_length);
1210             const parsed = try read(scratch, &source.interface, "GET");
1211             try std.testing.expectEqualStrings(case.body, parsed.body);
1212             try std.testing.expectEqualStrings(
1213                 "TAIL",
1214                 try source.interface.peek(4),
1215             );
1216         }
1217     }
1218 }
1219 
1220 test "Client response read preserves tails at exact body capacity" {
1221     var headers: [1]Header = undefined;
1222     var head_storage: [64]u8 = undefined;
1223 
1224     var fixed_body: [3]u8 = undefined;
1225     var fixed_reader = std.Io.Reader.fixed(
1226         "HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabcTAIL",
1227     );
1228     const fixed = try read(
1229         .{
1230             .headers = &headers,
1231             .head = &head_storage,
1232             .body = &fixed_body,
1233         },
1234         &fixed_reader,
1235         "GET",
1236     );
1237     try std.testing.expectEqualStrings("abc", fixed.body);
1238     try std.testing.expectEqualStrings("TAIL", try fixed_reader.peekGreedy(1));
1239 
1240     const encoded = "3\r\nabc\r\n0\r\n\r\n";
1241     var chunk_body: [encoded.len]u8 = undefined;
1242     var chunk_reader = std.Io.Reader.fixed(
1243         "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" ++
1244             encoded ++ "TAIL",
1245     );
1246     const chunked = try read(
1247         .{
1248             .headers = &headers,
1249             .head = &head_storage,
1250             .body = &chunk_body,
1251         },
1252         &chunk_reader,
1253         "GET",
1254     );
1255     try std.testing.expectEqualStrings("abc", chunked.body);
1256     try std.testing.expectEqualStrings("TAIL", try chunk_reader.peekGreedy(1));
1257 }
1258 
1259 test "Client response read preserves a head across reader buffers" {
1260     const value = @as([(8200) * ("a").len]u8, @bitCast(@as([8200][("a").len]u8, @splat(("a")[0..("a").len].*))));
1261     const head_text = "HTTP/1.1 200 OK\r\nX-Large: " ++ value ++ "\r\nContent-Length: 5\r\n\r\n";
1262     const raw = head_text ++ "Hello";
1263     var headers: [2]Header = undefined;
1264     var head_storage: [head_text.len]u8 = undefined;
1265     var body: [5]u8 = undefined;
1266     var reader = std.Io.Reader.fixed(raw);
1267     const parsed = try read(
1268         .{ .headers = &headers, .head = &head_storage, .body = &body },
1269         &reader,
1270         "GET",
1271     );
1272 
1273     try std.testing.expectEqualStrings(&value, parsed.header("X-Large").?);
1274     try std.testing.expectEqualStrings("Hello", parsed.body);
1275 }