Skip to content

Commit 41233d9

Browse files
bobzhangclaude
andcommitted
fix(json): reject raw 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), well-formed input cannot contain a raw lone surrogate in the first place — but unsafe code can manufacture such a String, and robustness demands a clean error over a process abort or an ill-formed result. The string lexer now rejects raw unpaired surrogates with the documented ParseError (InvalidChar) on both the fast path and the slow path, and flush slices with the bounds-check-only view(start_offset~, end_offset~) so no abort path remains. Escaped surrogate sequences (\uXXXX) are out of scope here; unpaired escape handling is tracked in #4062 and fixed separately. Deterministic regression tests in lex_string_test.mbt cover escape-free and escape-adjacent raw lone surrogates (the former abort), the exact ParseError shape, and still-accepted well-formed pairs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e4a037e commit 41233d9

2 files changed

Lines changed: 72 additions & 5 deletions

File tree

json/lex_string.mbt

Lines changed: 38 additions & 5 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,23 @@ 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 a raw unpaired
41+
// surrogate must be rejected rather than smuggled into the parsed
42+
// string. (Well-formed input cannot contain one, but unsafe code can
43+
// manufacture such a String; a clean error beats undefined behavior.)
44+
ctx.offset = i + 1
45+
ctx.invalid_char(shift=-1)
46+
} else if c.is_trailing_surrogate() {
47+
// A bare trailing surrogate can never start a surrogate pair.
48+
ctx.offset = i + 1
49+
ctx.invalid_char(shift=-1)
3250
}
51+
continue i + 1
3352
}
3453
raise InvalidEof
3554
}
@@ -39,8 +58,14 @@ fn ParseContext::lex_string_slow(ctx : ParseContext) -> String raise ParseError
3958
let buf = StringBuilder()
4059
let mut start = ctx.offset
4160
fn flush(end : Int) {
42-
if start > 0 && end > start {
43-
buf.write_view(ctx.input[start:end])
61+
if end > start {
62+
// `view(start_offset~, end_offset~)` only bounds-checks. The checked
63+
// `ctx.input[start:end]` would abort on a trailing surrogate at a
64+
// slice boundary, which a raw lone surrogate inside the string could
65+
// place there; unpaired surrogates now raise a ParseError before any
66+
// flush spans them, and the unchecked slice keeps this loop's
67+
// totality independent of that validation order.
68+
buf.write_view(ctx.input.view(start_offset=start, end_offset=end))
4469
}
4570
}
4671

@@ -70,12 +95,20 @@ fn ParseContext::lex_string_slow(ctx : ParseContext) -> String raise ParseError
7095
}
7196
start = ctx.offset
7297
}
73-
Some(ch) =>
74-
if ch.to_int() < 32 {
98+
Some(ch) => {
99+
let code = ch.to_int()
100+
if code < 32 {
101+
ctx.invalid_char(shift=-1)
102+
} else if code is (0xD800..=0xDFFF) {
103+
// `read_char` only yields a surrogate-range value when the raw
104+
// code unit is unpaired; keep parsed strings Unicode well-formed
105+
// by rejecting it (previously this aborted the process when a
106+
// later flush sliced across the surrogate).
75107
ctx.invalid_char(shift=-1)
76108
} else {
77109
continue
78110
}
111+
}
79112
None => raise InvalidEof
80113
}
81114
}

json/lex_string_test.mbt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,37 @@ 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 raw unpaired surrogates with a ParseError.
96+
/// Previously the escape-free fast path silently accepted them (producing
97+
/// ill-formed strings), and combining one with an escape sequence aborted
98+
/// the process: the slow path's `flush` sliced with the checked
99+
/// `[start:end]`, which panics when a slice boundary lands on a trailing
100+
/// surrogate. Well-formed input cannot contain raw lone surrogates, but
101+
/// unsafe code can manufacture such a String; a clean error beats an abort.
102+
test "raw unpaired surrogates are rejected with a clean parse error" {
103+
let lone_high = String::from_array([(0xD800).unsafe_to_char()])
104+
let lone_low = String::from_array([(0xDC00).unsafe_to_char()])
105+
// Escape-free strings (fast path; used to be silently accepted).
106+
assert_false(@json.valid("\"" + lone_high + "\""))
107+
assert_false(@json.valid("\"" + lone_low + "\""))
108+
// A reversed pair (low then high) is two unpaired surrogates.
109+
assert_false(@json.valid("\"" + lone_low + lone_high + "\""))
110+
// Next to escapes (slow path; used to abort the process).
111+
assert_false(@json.valid("\"" + lone_low + "\\n\""))
112+
assert_false(@json.valid("\"\\n" + lone_low + "\""))
113+
assert_false(@json.valid("\"" + lone_high + "\\t\""))
114+
// The failure is the documented ParseError, not an abort.
115+
debug_inspect(
116+
expect_parse_error("\"" + lone_low + "\"", "expected InvalidChar"),
117+
content=(
118+
#|InvalidChar({ line: 1, column: 1 }, '�')
119+
),
120+
)
121+
// Well-formed surrogate pairs still parse, raw or next to escapes.
122+
assert_true(@json.parse("\"\u{1F600}\"") == Json::string("\u{1F600}"))
123+
let json = Json::string("\u{10FFFF}\\\u{1F600}\n")
124+
assert_true(@json.parse(json.stringify()) == json)
125+
}

0 commit comments

Comments
 (0)