Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion json/lex_string.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,38 @@ fn ParseContext::lex_string_slow(ctx : ParseContext) -> String raise ParseError
Some('/') => buf.write_char('/')
Some('u') => {
let c = ctx.lex_hex_digits(4)
buf.write_char(c.unsafe_to_char())
if c is (0xD800..=0xDBFF) {
// A leading-surrogate escape is only meaningful as the first
// half of an escaped surrogate pair; combine it with the
// immediately following trailing-surrogate escape into one
// Unicode scalar value. Anything else would manufacture a
// string containing an unpaired surrogate, which MoonBit
// strings disallow (RFC 8259 calls the behavior for such
// escapes unpredictable; I-JSON forbids them).
match ctx.read_char() {
Some('\\') => ()
Some(_) => ctx.invalid_char(shift=-1)
None => raise InvalidEof
}
match ctx.read_char() {
Some('u') => ()
Some(_) => ctx.invalid_char(shift=-1)
None => raise InvalidEof
}
let c2 = ctx.lex_hex_digits(4)
if c2 is (0xDC00..=0xDFFF) {
let combined = (c << 10) + c2 - 0x35fdc00
buf.write_char(combined.unsafe_to_char())
} else {
ctx.invalid_char(shift=-1)
}
} else if c is (0xDC00..=0xDFFF) {
// A bare trailing-surrogate escape can never form a scalar
// value.
ctx.invalid_char(shift=-1)
} else {
buf.write_char(c.unsafe_to_char())
}
}
Some(_) => ctx.invalid_char(shift=-1)
None => raise InvalidEof
Expand Down
42 changes: 42 additions & 0 deletions json/lex_string_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,45 @@ test "lex_hex_digits accepts all hex digit ranges" {
),
)
}

///|
/// Regression for #4062. The parser used to decode every `\uXXXX` escape by
/// writing the hex value into the result unchecked, manufacturing an
/// ill-formed lone-surrogate string out of valid ASCII JSON input. MoonBit
/// strings stay Unicode well-formed, so an escaped leading surrogate must be
/// immediately followed by an escaped trailing surrogate (the pair combines
/// into one scalar value); every unpaired spelling is now a ParseError.
/// RFC 8259 calls the behavior for unpaired surrogate escapes unpredictable
/// and RFC 7493 (I-JSON) forbids them; rejecting matches serde_json.
test "unpaired surrogate escapes are rejected with a clean parse error" {
// An unpaired leading-surrogate escape.
assert_false(@json.valid("\"\\uD800\""))
assert_false(@json.valid("\"\\uD800x\""))
assert_false(@json.valid("\"\\uD800\\n\""))
// A bare trailing-surrogate escape.
assert_false(@json.valid("\"\\uDC00\""))
assert_false(@json.valid("\"a\\uDFFF b\""))
// Two leading-surrogate escapes in a row.
assert_false(@json.valid("\"\\uD800\\uD800\""))
// An escaped pair in reverse order.
assert_false(@json.valid("\"\\uDE00\\uD83D\""))
// Mixed escaped/raw halves do not pair up.
let lone_low = String::from_array([(0xDC00).unsafe_to_char()])
assert_false(@json.valid("\"\\uD800" + lone_low + "\""))
// The failure is the documented ParseError, not an ill-formed string.
debug_inspect(
expect_parse_error("\"\\uDC00\"", "expected InvalidChar"),
content=(
#|InvalidChar({ line: 1, column: 6 }, '0')
),
)
// Valid escaped pairs still combine into one scalar value, with any hex
// digit case, in any position.
assert_true(@json.parse("\"\\uD83D\\uDE00\"") == Json::string("\u{1F600}"))
assert_true(@json.parse("\"\\udbff\\udfff\"") == Json::string("\u{10FFFF}"))
assert_true(
@json.parse("\"a\\uD83D\\uDE00b\\n\"") == Json::string("a\u{1F600}b\n"),
)
// Non-surrogate escapes are unaffected.
assert_true(@json.parse("\"\\u0041\\uFFFD\"") == Json::string("A\u{FFFD}"))
}
Loading