Skip to content

fix(http): decode text() bodies losslessly, never abort on bad UTF-8 - #13

Merged
ehsanmok merged 1 commit into
mainfrom
fix/text-lossy-utf8
Aug 27, 2026
Merged

fix(http): decode text() bodies losslessly, never abort on bad UTF-8#13
ehsanmok merged 1 commit into
mainfrom
fix/text-lossy-utf8

Conversation

@ehsanmok

Copy link
Copy Markdown
Owner

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() and MultipartPart.text() still mangle non-ASCII. Both carried the same per-byte out += chr(Int(b)) loop, which maps each byte to the code point of the same value. Confirmed on main before this change:

Request("POST", "http://x/", _bytes("café 日本語")).text()  # -> "café æ¥æ¬èª"
form.value("n")                                              # -> "café æ¥æ¬èª"

This is the server-side ingest path, reached through the public MultipartForm.value() and Request.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 at docs/benchmark.md:705-708 and flare/http/proto/ascii.mojo:51-61. What it does on failure is the problem. Measured with a body of a <0xFF> b:

build before this PR after
default 97 255 98 - raw 0xFF survives into an ill-formed String 97 239 191 189 98
-D ASSERT=all abort: Assert Error: String: span is not valid UTF-8 97 239 191 189 98

Since the peer chooses those bytes, that is a remotely-triggerable abort on any assert-enabled build, and pixi run tests-asserts-all is 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-formed String. 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() and MultipartPart.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, as proto/ascii.mojo does) is named in the comment. text() is not a hot path today; the parser is, and it already uses ascii_unchecked_string.

Test plan

  • test_response_text_invalid_utf8_replaced - a <0xFF> b becomes a U+FFFD b
  • test_response_text_truncated_sequence_is_one_replacement - truncated E6 97 collapses to a single U+FFFD, pinning maximal-subpart behavior rather than one U+FFFD per byte
  • test_test_post_body_utf8_roundtrip and test_text_part_utf8_value - non-ASCII round-trips through Request.text() and a multipart field value; both fail on main
  • Verified no abort with and without -D ASSERT=all, where the pre-change code aborted
  • test-http 44/44 (also 44/44 under -D ASSERT=all), test-multipart 20, test-request-factories 10, test-server 84, test-auth-extract 40, test-extractors-concrete 31, test-extractors 11, test-typed-extractors 9, test-request-chunks 10, test-handler 14, test-form 25, test-session 24, test-sse, test-inbound-body, test-proto-reexports all pass
  • fuzz-multipart 0 crashes, check-no-http-http2-cycle clean, format-check clean

Made with Cursor

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>
@ehsanmok
ehsanmok merged commit c6f0084 into main Aug 27, 2026
6 checks passed
@ehsanmok
ehsanmok deleted the fix/text-lossy-utf8 branch August 27, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant