fix(json): reject unpaired \uXXXX surrogate escapes in strings - #4064
Conversation
Fixes #4062. The parser decoded every \uXXXX escape by writing the hex value into the result unchecked (buf.write_char(c.unsafe_to_char())), so "\uD800" manufactured an ill-formed lone-surrogate string out of perfectly valid ASCII JSON input, violating the String well-formedness invariant. An escaped leading surrogate (\uD800-\uDBFF) must now be immediately followed by an escaped trailing surrogate (\uDC00-\uDFFF); the pair is combined into one Unicode scalar value. A bare trailing-surrogate escape, an unpaired leading-surrogate escape, and mixed escaped/raw halves raise the documented ParseError (InvalidChar). Spec position: RFC 8259 section 8.2 flags unpaired surrogate escapes as unpredictable-behavior territory and RFC 7493 (I-JSON) forbids them. Ecosystem: JS JSON.parse and Python accept them, Go substitutes U+FFFD, serde_json rejects - we align with serde_json. BEHAVIOR CHANGE: "\uD800" previously parsed successfully into an ill-formed string and is now a parse error. Valid escaped pairs (\uD83D\uDE00) parse exactly as before. Deterministic regression tests in lex_string_test.mbt cover every unpaired spelling, the exact ParseError shape, and still-accepted valid pairs in both hex digit cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a JSON string-escape decoding bug where \uXXXX escapes in the surrogate range could previously construct ill-formed (lone-surrogate) String values, by validating and combining escaped surrogate pairs and rejecting unpaired surrogate escapes with the documented ParseError.
Changes:
- Update
ParseContext::lex_string_slowto (1) require\uD800..\uDBFFleading surrogates to be immediately followed by a\uDC00..\uDFFFtrailing surrogate escape, and (2) reject bare trailing-surrogate escapes. - Combine valid escaped surrogate pairs into a single Unicode scalar value before writing into the output buffer.
- Add regression tests covering unpaired surrogate spellings, expected error shape, and still-accepted valid escaped pairs.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| json/lex_string.mbt | Adds surrogate-pair validation/combination logic to \uXXXX handling in the slow string lexer path to prevent constructing ill-formed strings. |
| json/lex_string_test.mbt | Adds deterministic regression tests for rejecting unpaired surrogate escapes and accepting valid escaped pairs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Coverage Report for CI Build 6103Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage increased (+0.06%) to 90.73%Details
Uncovered ChangesNo uncovered changes found. Coverage Regressions1 previously-covered line in 1 file lost coverage.
Coverage Stats
💛 - Coveralls |
Codex CLI reviewRun with Thanks @bobzhang. My bottom line for reviewers: the surrogate-pair decoding, scalar reconstruction, buffer writes, and cursor bookkeeping are correct, and all four targets pass. I agree that strict rejection is a reasonable default because returning an ill-formed Correctness The new branch handles the requested cases as follows:
The reconstruction is algebraically correct: Thus The fast/slow interaction is also sound. Every escaped spelling contains a backslash, so the fast path unconditionally falls back before materializing the string. After a valid pair, both six-code-unit escapes have been consumed and All textual entry points converge here: values use One real error-offset defect remains: the new mismatch arms at lines 74–82 call Validation passed read-only:
Against Should this be rejected at all? Yes, I think strict rejection is the right default, but it must be presented as MoonBit’s policy rather than as required JSON behavior. RFC 8259 §8.2 explicitly says its ABNF admits unpaired surrogate escapes, while §9 both requires grammar-conforming input to be accepted and permits implementations to limit string character contents. I-JSON forbids surrogates, but it is a separate profile that also forbids noncharacters and duplicate member names; The compatibility cost is real: ECMAScript’s
That makes fail-fast rejection preferable as the strict default. MoonBit already follows this pattern for UTF-8 with strict Findings
VERDICT: REQUEST CHANGES I reproduced the error-offset defect from Finding 2 independently before posting, since it is the one concrete code bug here: The ASCII and BMP cases report the real character at the right column; the non-BMP one reports a broken half at column 8. |
Documents the contract, fixes where the error points, and splits the regression test. The accepted language is now written down. `parse` says that every string in the result is well-formed Unicode, that an escaped leading surrogate must be followed by an escaped trailing one, and — the part that was missing — that this is a limit on what a string may *contain*, which RFC 8259 §9 leaves to the implementation. It is not a claim about which documents are grammatically well formed (§8.2 admits these escapes) nor of I-JSON conformance, which restricts more than this. The reason is stated too: `String` is required to be well-formed, so the alternatives are handing back one that is not, or substituting U+FFFD and silently merging two distinct keys. `valid` now defines validity as "what `parse` accepts" instead of leaving it open. The README gains the same in short form with a runnable example, and CHANGELOG records the break, including that JavaScript's `JSON.stringify` emits these escapes so input other parsers accept can now be rejected. The error position had two problems. It named whichever character the scan stopped on, so `"\uDC00"` blamed the final, valid, hex digit; and it was computed as `shift=-1` after `read_char()`, which lands inside a non-BMP character — `"\uD800😀"` reported a broken half one column too far. Both are replaced by one documented convention: the position is the backslash that opens the offending escape, wherever the scan stopped. `lex_hex_digits` and the unknown-escape arm had the same `-1` defect for a non-BMP character and now use `-c.utf16_len()`, as `lex_main.mbt` already did. The single regression test became six, so one failing assertion can no longer mask the rest, and the matrix grew: U+10000 as well as U+10FFFF, EOF and malformed or short hex in the second escape, adjacent pairs, object keys as the other route into string lexing, and exact error assertions pinning the position convention. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up nits from review. Two doc claims were imprecise. "Each escape must denote a scalar value" is not true of either half of an accepted pair, so it now says an escape must denote a scalar on its own *or* be one half of a correctly ordered pair. And the promise that a failed pairing always raises `InvalidChar` at the backslash overstated it: running out of input still raises `InvalidEof`, and a malformed second escape is reported as the hex-digit error it is. Both are qualified now. The comparison to other parsers is narrowed too — serde_json rejects when parsing into `String`/`Value` while its byte mode admits WTF-8, and it is Go's `encoding/json` that substitutes U+FFFD, its v2 parser being stricter. The test preamble claimed the old parser accepted every rejection below it, which was false: the ones that also run out of input or misspell the second escape were already errors. Corrected, and the escaped-leading + raw-trailing case moved last, since that is the one that aborted the process outright before this change and would otherwise mask the blocks after it. Added the exact assertions that were missing: the second mismatch arm, a second escape that is well formed but is not a trailing surrogate, and a trailing backslash at EOF — all pinning the one documented position. Also covers the two arms whose `shift` was corrected, where an unknown escape naming a non-BMP character and a non-BMP character among hex digits are now reported whole rather than as a broken half one column further on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex CLI re-reviewSecond pass with Thanks for the thorough follow-up. I re-reviewed both commits and found no remaining merge blocker.
Validation passed:
The tracked worktree remained clean. VERDICT: APPROVE WITH NITS The remaining nits are applied in
Not taken: exposing the code-unit basis in Full matrix green after the follow-up: |
Fixes #4062
The bug
The parser decodes every
\uXXXXescape by writing the hex value into the result unchecked (buf.write_char(c.unsafe_to_char())inlex_string_slow), so"\uD800"manufactures an ill-formed lone-surrogate string out of perfectly valid ASCII JSON input — user code that never touches unsafe APIs ends up holding an ill-formedString, violating the well-formedness invariant ("we should maintain String unicode safe, sounsafe_to_charis indeed unsafe").Spec position
Per RFC 8259 the
\uXXXXescape syntax is grammatically valid for any hex value, but §8.2 explicitly flags unpaired surrogate escapes as unpredictable-behavior territory; RFC 7493 (I-JSON) forbids them outright. Ecosystem: JSJSON.parseand Python accept them (producing ill-formed strings), Go substitutes U+FFFD, serde_json (Rust) rejects — we align with serde_json.The fix
An escaped leading surrogate (
\uD800–\uDBFF) must be immediately followed by an escaped trailing surrogate (\uDC00–\uDFFF); the pair is combined into one Unicode scalar value. A bare trailing-surrogate escape, an unpaired leading-surrogate escape, and mixed escaped/raw halves raise the documentedParseError(InvalidChar). Valid escaped pairs (\uD83D\uDE00→ 😀), any hex digit case, and all non-surrogate escapes parse exactly as before."\uD800"(and every other unpaired surrogate escape) previously parsed successfully and now raises a parse error. No test or snapshot in the repository relied on the old acceptance.Scope and relation to #4056
This PR covers only the escaped spellings and is based on
main, independent of #4056 (the diff regions inlex_string.mbtdo not textually overlap: #4056 touches the fast path,flush, and the raw-character branch; this PR touches only the\uescape branch). Raw (unescaped) lone surrogates — including the process abort — are #4049, fixed in #4056.Tests
Deterministic regressions in
json/lex_string_test.mbt: every unpaired spelling (leading alone, bare trailing, double leading, reversed escaped pair, escaped-leading + raw-trailing), the exactParseErrorshape, and still-accepted valid pairs.Verified standalone on this branch:
moon checkclean; json suite green on wasm-gc, js, native (201/201 each); no.mbtichanges. Found by the adversarial QuickCheck suite in #4045.🤖 Generated with Claude Code