strict_fixture.mojo (43289B)
1 """Strict bounded HTTP request framing and scripted exchanges for local test 2 fixtures (ADR-0012 D29, ADR-0014 D33 FX01-FX05). 3 4 This module owns the *test fixture* wire contract only. It deliberately does 5 not change HYF's product HTTP/TLS policy or timeouts. 6 7 Framing contract (FX03): 8 9 * bounded byte-oriented accumulation before any UTF-8 decode 10 * header cap 65,536 bytes and body cap 1,048,576 bytes (ADR-0010 D21) 11 * lexical ``Content-Length``: nonempty ASCII digits only, with an explicit 12 overflow guard; no sign, whitespace or non-digit 13 * request line must be ``METHOD SP target SP HTTP/1.1|HTTP/1.0`` 14 * malformed header syntax (whitespace before ``:``, obs-fold, control bytes, 15 non-token name bytes) is rejected rather than silently stripped 16 * duplicate/conflicting framing and unsupported transfer encodings rejected 17 * premature EOF and oversize rejected before unbounded accumulation 18 * surplus bytes after a frame are retained for the next frame 19 20 Exchange contract (FX01/FX02/FX04/FX05): explicit ordered scripts carrying the 21 expected method/path/selected headers/body plus the scripted status/headers/ 22 body/delay and connection semantics. Request and connection counters are 23 distinct and verified. 24 """ 25 26 from std.collections import List 27 from std.ffi import ErrNo, get_errno 28 29 from flare.net import Timeout 30 from flare.tcp import TcpListener 31 from flare.tcp import TcpStream 32 from flare.utils import usleep 33 34 35 comptime STRICT_MAX_HEADER_BYTES = 65536 36 comptime STRICT_MAX_BODY_BYTES = 1048576 37 comptime STRICT_MAX_READY_BYTES = 256 38 comptime STRICT_MAX_REPORT_BYTES = 2048 39 comptime STRICT_COMPLETION_GRACE_MS = 100 40 41 42 # ── JSON-safe escaping (FX04) ─────────────────────────────────────────────── 43 44 45 def _hex_digit(value: Int) -> UInt8: 46 if value < 10: 47 return UInt8(48 + value) 48 return UInt8(97 + (value - 10)) 49 50 51 def json_escape(value: String) -> String: 52 """Return ``value`` as a JSON string literal with full control escaping.""" 53 var out = List[UInt8]() 54 out.append(UInt8(34)) 55 for byte in value.as_bytes(): 56 var b = Int(byte) 57 if b == 34: 58 out.append(UInt8(92)) 59 out.append(UInt8(34)) 60 elif b == 92: 61 out.append(UInt8(92)) 62 out.append(UInt8(92)) 63 elif b == 8: 64 out.append(UInt8(92)) 65 out.append(UInt8(98)) 66 elif b == 12: 67 out.append(UInt8(92)) 68 out.append(UInt8(102)) 69 elif b == 10: 70 out.append(UInt8(92)) 71 out.append(UInt8(110)) 72 elif b == 13: 73 out.append(UInt8(92)) 74 out.append(UInt8(114)) 75 elif b == 9: 76 out.append(UInt8(92)) 77 out.append(UInt8(116)) 78 elif b < 32 or b == 127: 79 out.append(UInt8(92)) 80 out.append(UInt8(117)) 81 out.append(UInt8(48)) 82 out.append(UInt8(48)) 83 out.append(_hex_digit(b // 16)) 84 out.append(_hex_digit(b % 16)) 85 else: 86 out.append(UInt8(b)) 87 out.append(UInt8(34)) 88 return String(unsafe_from_utf8=Span(ptr=out.unsafe_ptr(), length=len(out))) 89 90 91 # ── Header access (FX03/FX04) ─────────────────────────────────────────────── 92 93 94 def header_values(headers_raw: String, name: String) -> List[String]: 95 """Return every value for an exactly named header (case-insensitive name). 96 97 The header name is compared byte-exactly after lowercasing; malformed 98 spacing was already rejected by the framer, so no whitespace stripping of 99 the name token occurs here. 100 """ 101 var out = List[String]() 102 var lines = headers_raw.split("\r\n") 103 var wanted = name.lower() 104 for i in range(1, len(lines)): 105 var line = String(lines[i]) 106 var colon = line.find(":") 107 if colon <= 0: 108 continue 109 if String(line[byte=0:colon]).lower() == wanted: 110 out.append(String(line[byte = colon + 1 :].strip())) 111 return out^ 112 113 114 def header_value(headers_raw: String, name: String) -> String: 115 var values = header_values(headers_raw, name) 116 if len(values) != 1: 117 return "" 118 return values[0] 119 120 121 def bearer_token(headers_raw: String) -> String: 122 var values = header_values(headers_raw, "authorization") 123 if len(values) != 1: 124 return "" 125 if not values[0].startswith("Bearer "): 126 return "" 127 return String(values[0][byte=7:]) 128 129 130 def authorization_reason(headers_raw: String, required: Bool) -> String: 131 """Validate the exact ``Authorization`` header, rejecting spoofs (FX04).""" 132 if not required: 133 return "" 134 var values = header_values(headers_raw, "authorization") 135 if len(values) == 0: 136 return "auth_missing" 137 if len(values) > 1: 138 return "auth_duplicate" 139 if not values[0].startswith("Bearer ") or values[0].byte_length() <= 7: 140 return "auth_invalid" 141 return "" 142 143 144 # ── Framing ───────────────────────────────────────────────────────────────── 145 146 147 @fieldwise_init 148 struct FramedRequest(Movable): 149 var ok: Bool 150 var error: String 151 var method: String 152 var path: String 153 var version: String 154 var headers_raw: String 155 var body: String 156 var content_length: Int 157 var keep_alive: Bool 158 var total_bytes: Int 159 160 161 def _token_byte(value: Int) -> Bool: 162 if value >= 48 and value <= 57: 163 return True 164 if value >= 65 and value <= 90: 165 return True 166 if value >= 97 and value <= 122: 167 return True 168 # RFC 7230 token punctuation. 169 return ( 170 value == 33 171 or value == 35 172 or value == 36 173 or value == 37 174 or value == 38 175 or value == 39 176 or value == 42 177 or value == 43 178 or value == 45 179 or value == 46 180 or value == 94 181 or value == 95 182 or value == 96 183 or value == 124 184 or value == 126 185 ) 186 187 188 def _valid_method(method: String) -> Bool: 189 if method.byte_length() == 0: 190 return False 191 for byte in method.as_bytes(): 192 var b = Int(byte) 193 if b < 65 or b > 90: 194 return False 195 return True 196 197 198 def _valid_version(version: String) -> Bool: 199 return version == "HTTP/1.1" or version == "HTTP/1.0" 200 201 202 def _valid_header_name(name: String) -> Bool: 203 if name.byte_length() == 0: 204 return False 205 for byte in name.as_bytes(): 206 if not _token_byte(Int(byte)): 207 return False 208 return True 209 210 211 def _valid_header_value(value: String) -> Bool: 212 for byte in value.as_bytes(): 213 var b = Int(byte) 214 if b == 9: 215 continue 216 if b < 32 or b == 127: 217 return False 218 return True 219 220 221 def _trim_ows(value: String) -> String: 222 """Trim only legal HTTP optional whitespace (SP / HTAB).""" 223 var start = 0 224 var end = value.byte_length() 225 var bytes = value.as_bytes() 226 while start < end and (Int(bytes[start]) == 32 or Int(bytes[start]) == 9): 227 start += 1 228 while end > start and ( 229 Int(bytes[end - 1]) == 32 or Int(bytes[end - 1]) == 9 230 ): 231 end -= 1 232 if start == 0 and end == value.byte_length(): 233 return String(value) 234 return String(value[byte=start:end]) 235 236 237 def _ascii_digits(value: String) -> Bool: 238 if value.byte_length() == 0: 239 return False 240 for byte in value.as_bytes(): 241 var b = Int(byte) 242 if b < 48 or b > 57: 243 return False 244 return True 245 246 247 def _bytes_string(bytes: List[UInt8], stop: Int) raises -> String: 248 return String(from_utf8=Span(ptr=bytes.unsafe_ptr(), length=stop)) 249 250 251 struct ConnectionReader(Movable): 252 var _stream: TcpStream 253 var _buffer: List[UInt8] 254 var _eof: Bool 255 256 def __init__(out self, var stream: TcpStream): 257 self._stream = stream^ 258 self._buffer = List[UInt8]() 259 self._eof = False 260 261 def has_buffered(self) -> Bool: 262 return len(self._buffer) > 0 263 264 def _read_more(mut self) raises -> Int: 265 var chunk = InlineArray[Byte, 4096](fill=0) 266 var n = self._stream.read(chunk.unsafe_ptr(), 4096) 267 if n <= 0: 268 self._eof = True 269 return 0 270 for index in range(Int(n)): 271 self._buffer.append(UInt8(Int(chunk[index]))) 272 return Int(n) 273 274 def write_all(mut self, text: String) raises: 275 self._stream.write_all(Span[UInt8, _](text.as_bytes())) 276 277 def _header_end(self) -> Int: 278 var i = 0 279 while i + 3 < len(self._buffer): 280 if ( 281 self._buffer[i] == 13 282 and self._buffer[i + 1] == 10 283 and self._buffer[i + 2] == 13 284 and self._buffer[i + 3] == 10 285 ): 286 return i 287 i += 1 288 return -1 289 290 def read(mut self) raises -> FramedRequest: 291 var outcome = FramedRequest( 292 ok=False, 293 error="", 294 method="", 295 path="", 296 version="", 297 headers_raw="", 298 body="", 299 content_length=0, 300 keep_alive=False, 301 total_bytes=0, 302 ) 303 var header_end = self._header_end() 304 while header_end < 0: 305 if self._eof: 306 outcome.error = ( 307 "empty" if len(self._buffer) 308 == 0 else "missing_header_terminator" 309 ) 310 return outcome^ 311 if len(self._buffer) > STRICT_MAX_HEADER_BYTES: 312 outcome.error = "header_too_large" 313 return outcome^ 314 _ = self._read_more() 315 # Recompute the terminator before any total-length check: a single 316 # coalesced chunk may carry a small header plus a large body, and 317 # only the header bytes count against the header cap. 318 header_end = self._header_end() 319 if header_end < 0 and len(self._buffer) > STRICT_MAX_HEADER_BYTES: 320 outcome.error = "header_too_large" 321 return outcome^ 322 if header_end > STRICT_MAX_HEADER_BYTES: 323 outcome.error = "header_too_large" 324 return outcome^ 325 326 var header_text = _bytes_string(self._buffer, header_end) 327 var lines = header_text.split("\r\n") 328 if len(lines) < 1: 329 outcome.error = "malformed_request_line" 330 return outcome^ 331 var request_line = String(lines[0]) 332 var parts = request_line.split(" ") 333 if len(parts) != 3: 334 outcome.error = "malformed_request_line" 335 return outcome^ 336 outcome.method = String(parts[0]) 337 outcome.path = String(parts[1]) 338 outcome.version = String(parts[2]) 339 if not _valid_method(outcome.method): 340 outcome.error = "malformed_method" 341 return outcome^ 342 if not _valid_version(outcome.version): 343 outcome.error = "malformed_version" 344 return outcome^ 345 if outcome.path.byte_length() == 0: 346 outcome.error = "malformed_target" 347 return outcome^ 348 349 var content_length = 0 350 var have_length = False 351 var have_transfer = False 352 for i in range(1, len(lines)): 353 var line = String(lines[i]) 354 if line.byte_length() == 0: 355 continue 356 var first = Int(line.as_bytes()[0]) 357 if first == 32 or first == 9: 358 outcome.error = "malformed_header" 359 return outcome^ 360 var colon = line.find(":") 361 if colon <= 0: 362 outcome.error = "malformed_header" 363 return outcome^ 364 var name = String(line[byte=0:colon]) 365 var raw_value = String(line[byte = colon + 1 :]) 366 if not _valid_header_name(name): 367 outcome.error = "malformed_header" 368 return outcome^ 369 var value = _trim_ows(raw_value) 370 if not _valid_header_value(value): 371 outcome.error = "malformed_header" 372 return outcome^ 373 var lower = name.lower() 374 if lower == "content-length": 375 if have_length: 376 outcome.error = "duplicate_content_length" 377 return outcome^ 378 if not _ascii_digits(value): 379 outcome.error = "malformed_content_length" 380 return outcome^ 381 if value.byte_length() > 10: 382 outcome.error = "content_length_overflow" 383 return outcome^ 384 var parsed = Int(value) 385 if parsed > STRICT_MAX_BODY_BYTES: 386 outcome.error = "body_too_large" 387 return outcome^ 388 content_length = parsed 389 have_length = True 390 elif lower == "transfer-encoding": 391 if have_transfer: 392 outcome.error = "duplicate_transfer_encoding" 393 return outcome^ 394 have_transfer = True 395 if value.lower() != "identity": 396 outcome.error = "unsupported_transfer_encoding" 397 return outcome^ 398 elif lower == "connection" and value.lower() == "keep-alive": 399 outcome.keep_alive = True 400 401 if have_transfer and have_length: 402 outcome.error = "conflicting_framing" 403 return outcome^ 404 if not have_length: 405 content_length = 0 406 outcome.content_length = content_length 407 408 var expected_total = header_end + 4 + content_length 409 while len(self._buffer) < expected_total: 410 if self._eof: 411 outcome.error = "premature_eof" 412 return outcome^ 413 _ = self._read_more() 414 415 outcome.headers_raw = header_text 416 outcome.body = String( 417 _bytes_string(self._buffer, expected_total)[byte = header_end + 4 :] 418 ) 419 var surplus = List[UInt8]() 420 for index in range(expected_total, len(self._buffer)): 421 surplus.append(self._buffer[index]) 422 self._buffer = surplus^ 423 outcome.ok = True 424 outcome.total_bytes = expected_total 425 return outcome^ 426 427 def probe_completion(mut self, grace_ms: Int) raises -> String: 428 """Bounded completion handshake after the final expected exchange. 429 430 Any already-buffered or subsequently received bytes mean the client 431 sent an extra exchange. A bounded timeout with no bytes is success; a 432 real read error is propagated as the exact read cause (never read as 433 success or as a timeout) and is recorded by the serve loop as a 434 bounded io_error. The Mojo error model cannot discriminate these 435 struct payloads by handler type, so the timeout is identified from the 436 rendered cause. 437 """ 438 if len(self._buffer) > 0: 439 return "extra_exchange_after_completion" 440 self._stream.set_recv_timeout(grace_ms) 441 var outcome = "" 442 try: 443 var n = self._read_more() 444 if n > 0: 445 outcome = "extra_exchange_after_completion" 446 except e: 447 var text = String(e) 448 if text.startswith("Timeout"): 449 return "" 450 raise Error("probe_completion_read_error:" + text) 451 return outcome^ 452 453 454 # ── Scripted exchanges (FX01/FX02/FX04/FX05) ──────────────────────────────── 455 456 457 @fieldwise_init 458 struct ExchangeScript(Copyable, Movable): 459 var case_label: String 460 var method: String 461 var path: String 462 var headers: String 463 var body: String 464 var check_body: Bool 465 var require_bearer: Bool 466 var status: Int 467 var response_headers: String 468 var response_body: String 469 var raw_response: String 470 var delay_ms: Int 471 var stall_after_head_ms: Int 472 var close_connection: Bool 473 var echo_authorization: Bool 474 var expect_peer_close: Bool 475 var expected_close_cause: String 476 var expected_close_phase: String 477 var inject_write_error: String 478 var inject_write_errno: Int 479 var close_before_response: Bool 480 481 def __copyinit__(out self, existing: Self): 482 self.case_label = existing.case_label 483 self.method = existing.method 484 self.path = existing.path 485 self.headers = existing.headers 486 self.body = existing.body 487 self.check_body = existing.check_body 488 self.require_bearer = existing.require_bearer 489 self.status = existing.status 490 self.response_headers = existing.response_headers 491 self.response_body = existing.response_body 492 self.raw_response = existing.raw_response 493 self.delay_ms = existing.delay_ms 494 self.stall_after_head_ms = existing.stall_after_head_ms 495 self.close_connection = existing.close_connection 496 self.echo_authorization = existing.echo_authorization 497 self.expect_peer_close = existing.expect_peer_close 498 self.expected_close_cause = existing.expected_close_cause 499 self.expected_close_phase = existing.expected_close_phase 500 self.inject_write_error = existing.inject_write_error 501 self.inject_write_errno = existing.inject_write_errno 502 self.close_before_response = existing.close_before_response 503 504 505 def exchange_script( 506 case_label: String, 507 method: String, 508 path: String, 509 status: Int, 510 body: String, 511 ) -> ExchangeScript: 512 return ExchangeScript( 513 case_label=case_label, 514 method=method, 515 path=path, 516 headers="", 517 body="", 518 check_body=False, 519 require_bearer=False, 520 status=status, 521 response_headers="", 522 response_body=body, 523 raw_response="", 524 delay_ms=0, 525 stall_after_head_ms=0, 526 close_connection=True, 527 echo_authorization=False, 528 expect_peer_close=False, 529 expected_close_cause="", 530 expected_close_phase="", 531 inject_write_error="", 532 inject_write_errno=-1, 533 close_before_response=False, 534 ) 535 536 537 def verify_exchange(script: ExchangeScript, framed: FramedRequest) -> String: 538 """Return ``""`` when the request matches the script, else a bounded reason. 539 """ 540 if framed.method != script.method: 541 return "method_mismatch" 542 if framed.path != script.path: 543 return "path_mismatch" 544 if script.check_body and framed.body != script.body: 545 return "body_mismatch" 546 var expected_headers = script.headers.split("\n") 547 for entry in expected_headers: 548 var expected = String(entry).strip() 549 if expected.byte_length() == 0: 550 continue 551 var split = expected.find(":") 552 if split <= 0: 553 return "malformed_script_header" 554 var name = String(expected[byte=0:split]) 555 if name.byte_length() == 0 or not _valid_header_name(name): 556 return "malformed_script_header" 557 var value = _trim_ows(String(expected[byte = split + 1 :])) 558 var values = header_values(framed.headers_raw, name) 559 if len(values) == 0: 560 return "header_missing:" + name 561 if len(values) > 1: 562 return "header_duplicate:" + name 563 if values[0] != value: 564 return "header_mismatch:" + name 565 var auth = authorization_reason(framed.headers_raw, script.require_bearer) 566 if auth != "": 567 return auth 568 return "" 569 570 571 def _substitute( 572 text: String, request_index: Int, connection_index: Int 573 ) -> String: 574 var out = text.replace("{request_index}", String(request_index)) 575 return out.replace("{connection_index}", String(connection_index)) 576 577 578 def render_response( 579 script: ExchangeScript, 580 request_headers_raw: String, 581 request_index: Int, 582 connection_index: Int, 583 ) -> String: 584 if script.raw_response != "": 585 return String(script.raw_response) 586 var reason = "OK" 587 if script.status == 401: 588 reason = "Unauthorized" 589 elif script.status == 404: 590 reason = "Not Found" 591 elif script.status == 429: 592 reason = "Too Many Requests" 593 elif script.status == 500: 594 reason = "Internal Server Error" 595 elif script.status == 503: 596 reason = "Service Unavailable" 597 elif script.status == 529: 598 reason = "Overloaded" 599 var body = _substitute( 600 script.response_body, request_index, connection_index 601 ) 602 if script.echo_authorization: 603 body = ( 604 '{"authorization":' 605 + json_escape(bearer_token(request_headers_raw)) 606 + "}" 607 ) 608 var extra = "" 609 if script.response_headers != "": 610 extra = script.response_headers + "\r\n" 611 var connection = "keep-alive" if not script.close_connection else "close" 612 return ( 613 "HTTP/1.1 " 614 + String(script.status) 615 + " " 616 + reason 617 + "\r\ncontent-type: application/json\r\n" 618 + extra 619 + "content-length: " 620 + String(body.byte_length()) 621 + "\r\nconnection: " 622 + connection 623 + "\r\n\r\n" 624 + body 625 ) 626 627 628 # ── Convenience-mode validation + reports ──────────────────────────────────── 629 630 631 def classify_write_error_cause(text: String) -> String: 632 """Bounded cause class for a response-write error observed by the fixture. 633 634 Mojo's error model cannot discriminate handler types, so the exact rendered 635 cause is classified. A script that declares an expected peer close must 636 match one of these bounded classes *and* the exact phase; every other write 637 error (an injected handler failure, a write timeout, an invalid descriptor) 638 is surfaced as a bounded fixture failure rather than tolerated. 639 """ 640 if text.find("injected_error") >= 0: 641 # The bounded test-only write-error seam is never a real peer close, so 642 # an injected error can never be accepted by declaring a peer-close 643 # cause (ADR-0020 TC01). 644 return "unrelated_error" 645 if text.find("Bad file descriptor") >= 0 or text.find("(errno 9)") >= 0: 646 return "invalid_descriptor" 647 if text.find("ConnectionReset") >= 0: 648 return "peer_reset" 649 if text.find("BrokenPipe") >= 0: 650 return "broken_pipe" 651 if text.find("Timeout") >= 0: 652 return "write_timeout" 653 return "unrelated_error" 654 655 656 def write_errno_class(errno_value: Int) -> String: 657 """Bounded class for a real write(2)/send(2) errno. 658 659 Mirrors the flare ``TcpStream.write`` mapping, so synthetic seams and real 660 failures are classified by the same vocabulary and a timeout or descriptor 661 error can be compared directly against a declared peer-close cause. 662 """ 663 if errno_value == Int(ErrNo.EPIPE.value): 664 return "broken_pipe" 665 if errno_value == Int(ErrNo.ECONNRESET.value): 666 return "peer_reset" 667 if errno_value == Int(ErrNo.EAGAIN.value) or errno_value == Int( 668 ErrNo.EWOULDBLOCK.value 669 ): 670 return "write_timeout" 671 if errno_value == Int(ErrNo.EBADF.value): 672 return "invalid_descriptor" 673 return "unrelated_error" 674 675 676 def render_write_api_error(errno_value: Int) -> String: 677 """Render an errno exactly as the flare write API would render it. 678 679 Used by the narrowly scoped syscall-result seam so the synthetic failure is 680 classified by the *same* path as a real write failure, and by the real 681 descriptor control to render the observed errno. It is a rendering helper 682 only and changes no product or fork source. 683 """ 684 if errno_value == Int(ErrNo.EAGAIN.value) or errno_value == Int( 685 ErrNo.EWOULDBLOCK.value 686 ): 687 return "Timeout: send" 688 if errno_value == Int(ErrNo.EPIPE.value): 689 return "BrokenPipe" 690 if errno_value == Int(ErrNo.ECONNRESET.value): 691 return "ConnectionReset" 692 if errno_value == Int(ErrNo.EBADF.value): 693 return "NetworkError(errno 9): Bad file descriptor (send)" 694 return "NetworkError(errno " + String(errno_value) + "): send error" 695 696 697 def is_peer_close_cause(cause: String) -> Bool: 698 """True only for the bounded peer-close classes a script may expect. 699 700 A declaration is structurally restricted to an actual peer close, so a 701 write timeout, an invalid descriptor or an unrelated handler error can 702 never be waived by naming it as the expected cause (ADR-0020 TC01). 703 """ 704 return cause == "peer_reset" or cause == "broken_pipe" 705 706 707 def is_expected_close_phase(phase: String) -> Bool: 708 """True only for a write step the fixture can actually be performing. 709 710 EC01: an expected-close declaration must name an observed phase, so an 711 unknown or empty phase is an invalid declaration and cannot be satisfied by 712 any real write step. 713 """ 714 return ( 715 phase == "delayed_write" 716 or phase == "head_write" 717 or phase == "body_stall" 718 ) 719 720 721 def is_valid_close_declaration(script: ExchangeScript) -> Bool: 722 """True when an expected-close declaration names a real cause and phase. 723 724 EC01: an invalid cause/phase declaration is rejected before any response 725 work, so a script can never succeed merely because response writing 726 happened to raise an unrelated error. 727 """ 728 return is_peer_close_cause( 729 script.expected_close_cause 730 ) and is_expected_close_phase(script.expected_close_phase) 731 732 733 def serve_scripts( 734 listener: TcpListener, var scripts: List[ExchangeScript], label: String 735 ) raises -> ServeReport: 736 """Serve an ordered list of explicit exchange scripts (FX01/FX02/FX05). 737 738 Every script must be consumed in order on its expected method/path/ 739 selected-headers/body; unexpected, extra, missing and unconsumed exchanges 740 fail with a bounded case-specific reason. A 404-then-success outcome is 741 never a pass. 742 """ 743 var request_count = 0 744 var connection_count = 0 745 var total = len(scripts) 746 var accepted_closes = List[String]() 747 try: 748 while request_count < total: 749 var stream = listener.accept() 750 connection_count += 1 751 var reader = ConnectionReader(stream^) 752 var close_connection = False 753 while request_count < total: 754 var framed = reader.read() 755 if not framed.ok: 756 if framed.error == "empty": 757 if request_count < total: 758 return ServeReport( 759 False, 760 "accounting", 761 label, 762 "missing_exchanges", 763 request_count, 764 connection_count, 765 ) 766 break 767 return ServeReport( 768 False, 769 "read", 770 label, 771 framed.error, 772 request_count, 773 connection_count, 774 ) 775 var script = scripts[request_count].copy() 776 var verify = verify_exchange(script, framed) 777 if verify != "": 778 return ServeReport( 779 False, 780 "exchange", 781 script.case_label, 782 verify, 783 request_count, 784 connection_count, 785 ) 786 # EC01: reject an invalid expected-close declaration before any 787 # response work, even when the write would have succeeded. 788 if script.expect_peer_close and not is_valid_close_declaration( 789 script 790 ): 791 return ServeReport( 792 False, 793 "declaration", 794 script.case_label, 795 "invalid_expected_close_declaration", 796 request_count, 797 connection_count, 798 ) 799 var next_index = request_count + 1 800 if next_index == total: 801 var extra = reader.probe_completion( 802 STRICT_COMPLETION_GRACE_MS 803 ) 804 if extra != "": 805 return ServeReport( 806 False, 807 "accounting", 808 script.case_label, 809 extra, 810 request_count, 811 connection_count, 812 ) 813 request_count = next_index 814 if script.close_before_response: 815 # RP01 raw-observation control: a peer that closes the 816 # connection without sending a response head at all. The 817 # raw observer must report a bounded raw_eof rather than 818 # hang or invent a status. 819 close_connection = True 820 break 821 if script.delay_ms > 0: 822 usleep(script.delay_ms * 1000) 823 var phase = "delayed_write" 824 try: 825 if script.stall_after_head_ms > 0: 826 # Headers-then-stall control (H007): write the complete 827 # response head, flush it, hold the connection open for 828 # the declared bounded stall, then attempt the body. This 829 # separates a body-read stall from a connection timeout 830 # and from the overall budget using a bounded fixture 831 # delay that never hangs the owning test. ``phase`` is 832 # the write step actually being attempted, not a script 833 # label, so a head-write failure is not reported as a 834 # body stall. 835 var rendered = render_response( 836 script, 837 framed.headers_raw, 838 request_count, 839 connection_count, 840 ) 841 var separator = rendered.find("\r\n\r\n") 842 if separator >= 0: 843 var head_end = separator + 4 844 phase = "head_write" 845 reader.write_all(String(rendered[byte=0:head_end])) 846 phase = "body_stall" 847 usleep(script.stall_after_head_ms * 1000) 848 if script.inject_write_error != "": 849 raise Error( 850 "injected_error: " 851 + script.inject_write_error 852 ) 853 if script.inject_write_errno >= 0: 854 raise Error( 855 "synthetic_errno: " 856 + render_write_api_error( 857 script.inject_write_errno 858 ) 859 ) 860 reader.write_all(String(rendered[byte=head_end:])) 861 else: 862 phase = "head_write" 863 reader.write_all(rendered) 864 else: 865 phase = "delayed_write" 866 if script.inject_write_error != "": 867 raise Error( 868 "injected_error: " + script.inject_write_error 869 ) 870 if script.inject_write_errno >= 0: 871 raise Error( 872 "synthetic_errno: " 873 + render_write_api_error( 874 script.inject_write_errno 875 ) 876 ) 877 reader.write_all( 878 render_response( 879 script, 880 framed.headers_raw, 881 request_count, 882 connection_count, 883 ) 884 ) 885 except e: 886 # A delay/stall is not permission to swallow every write 887 # error. Only an explicitly declared expected peer close 888 # whose exact bounded cause and phase match is accepted; 889 # unexpected/wrong-phase closes, write timeouts, invalid 890 # descriptors and unrelated handler errors fail the fixture 891 # with a bounded, cause-specific reason. The declared cause 892 # is itself restricted to a real peer-close class, so a 893 # timeout or unrelated error cannot be waived by declaring 894 # it as the expected cause. 895 # Record the raw errno observed here as well as the 896 # classification. A synthetic seam is labelled explicitly 897 # so it is never presented as a real OS timeout/EBADF. 898 var raw_errno = Int(get_errno().value) 899 var observed_text = String(e) 900 var synthetic = ( 901 observed_text.find("synthetic_errno") >= 0 902 or observed_text.find("injected_error") >= 0 903 ) 904 var observed = classify_write_error_cause(observed_text) 905 var evidence = ( 906 "synthdecl" 907 + String( 908 script.inject_write_errno 909 ) if synthetic else "realerrno" 910 + String(raw_errno) 911 ) 912 # OB03: provenance is kept separate from the error class. A 913 # synthetic/injected seam is never a real peer close, so it 914 # cannot satisfy an expected real close even when it maps to 915 # the same bounded class (EPIPE -> broken_pipe, ECONNRESET -> 916 # peer_reset). It is rejected here with its synthetic evidence 917 # label instead of being accepted. 918 if ( 919 script.expect_peer_close 920 and not synthetic 921 and is_peer_close_cause(script.expected_close_cause) 922 and observed == script.expected_close_cause 923 and phase == script.expected_close_phase 924 ): 925 # EC01: a permitted close consumes this exchange only. 926 # The connection is finished, so the sequence continues 927 # on a fresh connection and the exact request-count 928 # accounting still requires every remaining script. 929 accepted_closes.append( 930 script.case_label 931 + "_peer_close_" 932 + observed 933 + "_" 934 + phase 935 + "_" 936 + evidence 937 ) 938 close_connection = True 939 else: 940 return ServeReport( 941 False, 942 "peer_close", 943 script.case_label, 944 "unexpected_write_" 945 + observed 946 + "_" 947 + phase 948 + "_" 949 + evidence, 950 request_count, 951 connection_count, 952 ) 953 else: 954 # EC01: the script declared an expected peer close, but the 955 # response write succeeded, so the declared event never 956 # happened. A successful write is not permission to accept 957 # a declared close that was never observed. 958 if script.expect_peer_close: 959 return ServeReport( 960 False, 961 "peer_close", 962 script.case_label, 963 "missing_expected_close_" 964 + script.expected_close_phase, 965 request_count, 966 connection_count, 967 ) 968 if script.close_connection or close_connection: 969 break 970 if request_count < total: 971 return ServeReport( 972 False, 973 "accounting", 974 scripts[request_count].case_label, 975 "missing_exchanges", 976 request_count, 977 connection_count, 978 ) 979 var case_label = label 980 if len(accepted_closes) > 0: 981 case_label = "" 982 for index in range(len(accepted_closes)): 983 if index > 0: 984 case_label += ";" 985 case_label += accepted_closes[index] 986 return ServeReport( 987 True, "complete", case_label, "ok", request_count, connection_count 988 ) 989 except: 990 return ServeReport( 991 False, "read", label, "io_error", request_count, connection_count 992 ) 993 994 995 def validate_convenience( 996 framed: FramedRequest, 997 allowed_paths: List[String], 998 allowed_methods: List[String], 999 require_bearer: Bool, 1000 ) -> String: 1001 """Validate route then method (and auth) before any response. 1002 1003 ``allowed_methods`` is parallel to ``allowed_paths``. Wrong routes, 1004 methods and spoofed auth never count as a successful intended exchange 1005 (FX02/FX04). 1006 """ 1007 var matched = -1 1008 for index in range(len(allowed_paths)): 1009 if framed.path == allowed_paths[index]: 1010 matched = index 1011 if matched < 0: 1012 return "unexpected_path" 1013 if matched >= len(allowed_methods): 1014 return "unexpected_method" 1015 if framed.method != allowed_methods[matched]: 1016 return "unexpected_method" 1017 return authorization_reason(framed.headers_raw, require_bearer) 1018 1019 1020 @fieldwise_init 1021 struct ServeReport(Movable): 1022 var ok: Bool 1023 var phase: String 1024 var case_label: String 1025 var reason: String 1026 var requests: Int 1027 var connections: Int 1028 1029 def describe(self) -> String: 1030 return ( 1031 "phase=" 1032 + self.phase 1033 + " case=" 1034 + self.case_label 1035 + " reason=" 1036 + self.reason 1037 + " requests=" 1038 + String(self.requests) 1039 + " connections=" 1040 + String(self.connections) 1041 ) 1042 1043 1044 def report_status_matches_exit( 1045 exited: Bool, exit_code: Int, report_ok: Bool 1046 ) -> Bool: 1047 """The fixture child exits 0 for a successful report and 125 for a failed 1048 one; any other exit (including a signal) contradicts the report.""" 1049 if not exited: 1050 return False 1051 if report_ok: 1052 return exit_code == 0 1053 return exit_code == 125 1054 1055 1056 def report_line(report: ServeReport) -> String: 1057 var state = "ok" if report.ok else "fail" 1058 return "result " + state + " " + report.describe() 1059 1060 1061 def _strict_nonneg(value: String) -> Int: 1062 """Parse a nonnegative decimal count; -1 for empty/non-digit/overflow.""" 1063 if value.byte_length() == 0 or value.byte_length() > 12: 1064 return -1 1065 var total = 0 1066 for byte in value.as_bytes(): 1067 var b = Int(byte) 1068 if b < 48 or b > 57: 1069 return -1 1070 total = total * 10 + (b - 48) 1071 return total 1072 1073 1074 def _report_failure(reason: String) -> ServeReport: 1075 return ServeReport(False, "parse", "-", reason, -1, -1) 1076 1077 1078 def parse_report(line: String) -> ServeReport: 1079 """Strictly parse one bounded ``result`` report line. 1080 1081 Missing/truncated/duplicate/malformed/unknown/missing-count fields fail 1082 with a bounded cause instead of defaulting counts to zero, and only the 1083 exact ``ok``/``fail`` status is accepted. 1084 """ 1085 if not line.startswith("result "): 1086 return _report_failure("missing_result_prefix") 1087 var parts = String(line[byte=7:]).split(" ") 1088 if len(parts) == 0: 1089 return _report_failure("missing_status") 1090 var status = String(parts[0]) 1091 if status != "ok" and status != "fail": 1092 return _report_failure("unknown_status") 1093 var phase = "" 1094 var case_label = "" 1095 var reason = "" 1096 var requests = -1 1097 var connections = -1 1098 var have_phase = False 1099 var have_case = False 1100 var have_reason = False 1101 var have_requests = False 1102 var have_connections = False 1103 for index in range(1, len(parts)): 1104 var field = String(parts[index]) 1105 if field.byte_length() == 0: 1106 return _report_failure("empty_field") 1107 var eq = field.find("=") 1108 if eq <= 0: 1109 return _report_failure("malformed_field") 1110 var key = String(field[byte=0:eq]) 1111 var value = String(field[byte = eq + 1 :]) 1112 if value.byte_length() == 0: 1113 return _report_failure("empty_field") 1114 if key == "phase": 1115 if have_phase: 1116 return _report_failure("duplicate_field") 1117 have_phase = True 1118 phase = value 1119 elif key == "case": 1120 if have_case: 1121 return _report_failure("duplicate_field") 1122 have_case = True 1123 case_label = value 1124 elif key == "reason": 1125 if have_reason: 1126 return _report_failure("duplicate_field") 1127 have_reason = True 1128 reason = value 1129 elif key == "requests": 1130 if have_requests: 1131 return _report_failure("duplicate_field") 1132 have_requests = True 1133 requests = _strict_nonneg(value) 1134 if requests < 0: 1135 return _report_failure("invalid_count") 1136 elif key == "connections": 1137 if have_connections: 1138 return _report_failure("duplicate_field") 1139 have_connections = True 1140 connections = _strict_nonneg(value) 1141 if connections < 0: 1142 return _report_failure("invalid_count") 1143 else: 1144 return _report_failure("unknown_field") 1145 if not ( 1146 have_phase 1147 and have_case 1148 and have_reason 1149 and have_requests 1150 and have_connections 1151 ): 1152 return _report_failure("missing_field") 1153 if status == "ok" and (phase != "complete" or reason != "ok"): 1154 # A claimed success must carry the emitted success phase/reason; any 1155 # other pairing is an inconsistent report, never a success. 1156 return _report_failure("inconsistent_status") 1157 return ServeReport( 1158 status == "ok", phase, case_label, reason, requests, connections 1159 )