fix(http): decode text() bodies losslessly, never abort on bad UTF-8 - #13
Merged
Conversation
Request.text(), MultipartPart.text() and Response.text() all decode bytes a peer chose, so the decode has to be total. None of the three were: - Request.text() and MultipartPart.text() still used the per-byte ``out += chr(Int(b))`` loop, which maps each byte to the code point of the same value. "café 日本語" came back as "café æ¥æ¬èª" on the server-side ingest path, including through the public MultipartForm.value() and Request.json(). - Response.text() (#9) moved to String(unsafe_from_utf8=...), which runs the stdlib validator internally despite its name. On malformed input that validator passes the bytes straight through in the default build, yielding an ill-formed String, and aborts the process under -D ASSERT=all -- a remote peer could abort an assert-enabled build by returning a non-UTF-8 body. proto/utf8.utf8_lossy_string() replaces all three: well-formed input is bulk-copied, and each maximal ill-formed subpart becomes one U+FFFD per Unicode 15 3.9. The result is always well-formed, so it cannot abort and cannot produce an ill-formed String. That also makes the promise Response.text()'s docstring has always carried ("invalid UTF-8 sequences are replaced with the replacement character") true for the first time; Request/MultipartPart docstrings now state it too. WsFrame.text_payload() deliberately keeps validate-and-raise instead: RFC 6455 8.1 requires rejecting invalid UTF-8 on TEXT frames, not substituting. Verified: a <0xFF> b decodes to "a U+FFFD b" and a truncated E6 97 collapses to a single U+FFFD, identically with and without -D ASSERT=all, where the pre-change code aborted. Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes the family of
text()decode bugs that #9 and #12 started on. Closes the loop on the two copies #9 did not touch, and fixes the failure mode #9 introduced on the copy it did.The two problems
Request.text()andMultipartPart.text()still mangle non-ASCII. Both carried the same per-byteout += chr(Int(b))loop, which maps each byte to the code point of the same value. Confirmed onmainbefore this change:This is the server-side ingest path, reached through the public
MultipartForm.value()andRequest.json().Response.text()can abort the process.String(unsafe_from_utf8=Span)runs the stdlib UTF-8 validator internally despite its name, as this tree already documents atdocs/benchmark.md:705-708andflare/http/proto/ascii.mojo:51-61. What it does on failure is the problem. Measured with a body ofa <0xFF> b:97 255 98- raw0xFFsurvives into an ill-formedString97 239 191 189 98-D ASSERT=allAssert Error: String: span is not valid UTF-897 239 191 189 98Since the peer chooses those bytes, that is a remotely-triggerable abort on any assert-enabled build, and
pixi run tests-asserts-allis a supported posture here.The fix
proto/utf8.utf8_lossy_string()makes the decode total: well-formed input is bulk-copied, and each maximal ill-formed subpart becomes one U+FFFD per Unicode 15 3.9. The result is always well-formed UTF-8, so it can neither abort nor produce an ill-formedString. All three call sites use it.This also makes the promise
Response.text()'s docstring has carried all along - "invalid UTF-8 sequences are replaced with the Unicode replacement character" - true for the first time.Request.text()andMultipartPart.text()now document it as well.WsFrame.text_payload()is deliberately left on validate-and-raise: RFC 6455 8.1 requires rejecting invalid UTF-8 on TEXT frames rather than substituting, so #12 is the right shape there and this is not.One intentional shortcut is marked with a
ponytail:comment: the happy path validates twice, once in the scan and again inside the constructor. The upgrade path (unsafe_uninit_length+memcpy, asproto/ascii.mojodoes) is named in the comment.text()is not a hot path today; the parser is, and it already usesascii_unchecked_string.Test plan
test_response_text_invalid_utf8_replaced-a <0xFF> bbecomesa U+FFFD btest_response_text_truncated_sequence_is_one_replacement- truncatedE6 97collapses to a single U+FFFD, pinning maximal-subpart behavior rather than one U+FFFD per bytetest_test_post_body_utf8_roundtripandtest_text_part_utf8_value- non-ASCII round-trips throughRequest.text()and a multipart field value; both fail onmain-D ASSERT=all, where the pre-change code abortedtest-http44/44 (also 44/44 under-D ASSERT=all),test-multipart20,test-request-factories10,test-server84,test-auth-extract40,test-extractors-concrete31,test-extractors11,test-typed-extractors9,test-request-chunks10,test-handler14,test-form25,test-session24,test-sse,test-inbound-body,test-proto-reexportsall passfuzz-multipart0 crashes,check-no-http-http2-cycleclean,format-checkcleanMade with Cursor