Skip to content

Commit ca31837

Browse files
bobzhangclaude
andcommitted
fix(json): reject unpaired surrogates in string lexing instead of aborting
Fixes #4049. parse aborted (panic, not ParseError) on strings mixing a raw lone trailing surrogate with any escape sequence, e.g. the 5-code-unit text " U+DC00 \n ": lex_string_slow's flush sliced with the checked ctx.input[start:end], which aborts when the code unit at a slice boundary is a trailing surrogate. Meanwhile the escape-free fast path silently *accepted* raw lone surrogates, producing ill-formed strings. Per the design rule that MoonBit Strings stay Unicode well-formed (unsafe_to_char is indeed unsafe), the string lexer now rejects every unpaired surrogate with the documented ParseError (InvalidChar) instead of either aborting or letting it through: - raw lone leading/trailing surrogates are rejected on both the fast path and the slow path; - an escaped leading surrogate (\uD8xx) must be immediately followed by an escaped trailing surrogate; the pair is combined into one scalar value, anything else is rejected. BEHAVIOR CHANGE: "\uD800" alone previously parsed into an ill-formed lone-surrogate string and is now a parse error; - well-formed pairs (raw or escaped) parse exactly as before; - flush now slices with the bounds-check-only view(start_offset~, end_offset~), so no abort path remains in string lexing. Deterministic regression tests in lex_string_test.mbt cover every rejection shape (raw, escaped, mixed raw/escaped halves) plus the still-accepted well-formed pairs. Full repo suite green on wasm-gc; the json suite green on wasm-gc, js, and native. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d5a4518 commit ca31837

2 files changed

Lines changed: 115 additions & 6 deletions

File tree

json/lex_string.mbt

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ fn ParseContext::lex_string(ctx : ParseContext) -> String raise ParseError {
1717
let string_start = ctx.offset
1818
// Fast path for ordinary strings: scan raw UTF-16 code units and materialize
1919
// the slice directly when there are no escapes or control characters.
20-
for i in string_start..<ctx.end_offset {
20+
for i = string_start {
21+
if i >= ctx.end_offset {
22+
break
23+
}
2124
let c = ctx.input.unsafe_get(i)
2225
if c == '"' {
2326
ctx.offset = i + 1
@@ -29,7 +32,21 @@ fn ParseContext::lex_string(ctx : ParseContext) -> String raise ParseError {
2932
// \t) are invalid inside a JSON string.
3033
ctx.offset = i + 1
3134
ctx.invalid_char(shift=-1)
35+
} else if c.is_leading_surrogate() {
36+
if i + 1 < ctx.end_offset &&
37+
ctx.input.unsafe_get(i + 1).is_trailing_surrogate() {
38+
continue i + 2
39+
}
40+
// MoonBit strings stay Unicode well-formed, so an unpaired surrogate
41+
// must be rejected rather than smuggled into the parsed string.
42+
ctx.offset = i + 1
43+
ctx.invalid_char(shift=-1)
44+
} else if c.is_trailing_surrogate() {
45+
// A bare trailing surrogate can never start a surrogate pair.
46+
ctx.offset = i + 1
47+
ctx.invalid_char(shift=-1)
3248
}
49+
continue i + 1
3350
}
3451
raise InvalidEof
3552
}
@@ -39,8 +56,15 @@ fn ParseContext::lex_string_slow(ctx : ParseContext) -> String raise ParseError
3956
let buf = StringBuilder()
4057
let mut start = ctx.offset
4158
fn flush(end : Int) {
42-
if start > 0 && end > start {
43-
buf.write_view(ctx.input[start:end])
59+
if end > start {
60+
// `view(start_offset~, end_offset~)` only bounds-checks. The checked
61+
// `ctx.input[start:end]` would additionally abort on a trailing
62+
// surrogate at a boundary; that cannot happen here because every code
63+
// unit in the flushed run has already been validated (unpaired
64+
// surrogates raise a ParseError before any flush spans them), but the
65+
// unchecked slice keeps this loop's totality independent of that
66+
// validation order.
67+
buf.write_view(ctx.input.view(start_offset=start, end_offset=end))
4468
}
4569
}
4670

@@ -63,19 +87,55 @@ fn ParseContext::lex_string_slow(ctx : ParseContext) -> String raise ParseError
6387
Some('/') => buf.write_char('/')
6488
Some('u') => {
6589
let c = ctx.lex_hex_digits(4)
66-
buf.write_char(c.unsafe_to_char())
90+
if c is (0xD800..=0xDBFF) {
91+
// A leading-surrogate escape is only meaningful as the first
92+
// half of an escaped surrogate pair; combine it with the
93+
// immediately following trailing-surrogate escape into one
94+
// Unicode scalar value. Anything else would put an unpaired
95+
// surrogate into the result, which MoonBit strings disallow.
96+
match ctx.read_char() {
97+
Some('\\') => ()
98+
Some(_) => ctx.invalid_char(shift=-1)
99+
None => raise InvalidEof
100+
}
101+
match ctx.read_char() {
102+
Some('u') => ()
103+
Some(_) => ctx.invalid_char(shift=-1)
104+
None => raise InvalidEof
105+
}
106+
let c2 = ctx.lex_hex_digits(4)
107+
if c2 is (0xDC00..=0xDFFF) {
108+
let combined = (c << 10) + c2 - 0x35fdc00
109+
buf.write_char(combined.unsafe_to_char())
110+
} else {
111+
ctx.invalid_char(shift=-1)
112+
}
113+
} else if c is (0xDC00..=0xDFFF) {
114+
// A bare trailing-surrogate escape can never form a scalar
115+
// value.
116+
ctx.invalid_char(shift=-1)
117+
} else {
118+
buf.write_char(c.unsafe_to_char())
119+
}
67120
}
68121
Some(_) => ctx.invalid_char(shift=-1)
69122
None => raise InvalidEof
70123
}
71124
start = ctx.offset
72125
}
73-
Some(ch) =>
74-
if ch.to_int() < 32 {
126+
Some(ch) => {
127+
let code = ch.to_int()
128+
if code < 32 {
129+
ctx.invalid_char(shift=-1)
130+
} else if code is (0xD800..=0xDFFF) {
131+
// `read_char` only yields a surrogate-range value when the code
132+
// unit is unpaired; keep parsed strings Unicode well-formed by
133+
// rejecting it.
75134
ctx.invalid_char(shift=-1)
76135
} else {
77136
continue
78137
}
138+
}
79139
None => raise InvalidEof
80140
}
81141
}

json/lex_string_test.mbt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,52 @@ test "lex_hex_digits accepts all hex digit ranges" {
8989
),
9090
)
9191
}
92+
93+
///|
94+
/// Regression for #4049. MoonBit strings must stay Unicode well-formed, so
95+
/// the string lexer rejects every unpaired surrogate with a ParseError.
96+
/// Previously the escape-free fast path *accepted* raw lone surrogates
97+
/// (producing ill-formed strings), and combining one with an escape sequence
98+
/// aborted the process (checked slicing in the slow path's `flush` hit a
99+
/// surrogate at a slice boundary).
100+
test "unpaired surrogates are rejected with a clean parse error" {
101+
let lone_high = String::from_array([(0xD800).unsafe_to_char()])
102+
let lone_low = String::from_array([(0xDC00).unsafe_to_char()])
103+
// Raw lone surrogates, escape-free (fast path; used to be accepted).
104+
assert_false(@json.valid("\"" + lone_high + "\""))
105+
assert_false(@json.valid("\"" + lone_low + "\""))
106+
assert_false(@json.valid("\"" + lone_low + lone_high + "\""))
107+
// Raw lone surrogates next to escapes (slow path; used to abort).
108+
assert_false(@json.valid("\"" + lone_low + "\\n\""))
109+
assert_false(@json.valid("\"\\n" + lone_low + "\""))
110+
assert_false(@json.valid("\"" + lone_high + "\\t\""))
111+
// Escaped lone surrogates (used to produce ill-formed strings).
112+
assert_false(@json.valid("\"\\uD800\""))
113+
assert_false(@json.valid("\"\\uDC00\""))
114+
assert_false(@json.valid("\"\\uD800\\uD800\""))
115+
assert_false(@json.valid("\"\\uD800x\""))
116+
// Mixed raw/escaped halves do not pair up.
117+
assert_false(@json.valid("\"\\uD800" + lone_low + "\""))
118+
assert_false(@json.valid("\"" + lone_high + "\\uDC00\""))
119+
// The failure is the documented ParseError, not an abort.
120+
debug_inspect(
121+
expect_parse_error("\"\\uDC00\"", "expected InvalidChar"),
122+
content=(
123+
#|InvalidChar({ line: 1, column: 6 }, '0')
124+
),
125+
)
126+
}
127+
128+
///|
129+
/// Well-formed surrogate pairs still parse, raw or escaped (including a pair
130+
/// split across two `\uXXXX` escapes), and other strings are unaffected.
131+
test "well-formed surrogate pairs still parse" {
132+
assert_true(@json.parse("\"\\uD83D\\uDE00\"") == Json::string("\u{1F600}"))
133+
assert_true(@json.parse("\"\u{1F600}\"") == Json::string("\u{1F600}"))
134+
assert_true(
135+
@json.parse("\"a\\uD83D\\uDE00b\\n\"") == Json::string("a\u{1F600}b\n"),
136+
)
137+
// Roundtrip through stringify for an astral character next to an escape.
138+
let json = Json::string("\u{10FFFF}\\\u{1F600}")
139+
assert_true(@json.parse(json.stringify()) == json)
140+
}

0 commit comments

Comments
 (0)