Skip to content

Commit ecf2106

Browse files
bobzhangclaude
andcommitted
test(json): assert clean rejection of lone surrogates per unicode-safe policy
The parser now keeps strings Unicode well-formed (#4056): unpaired surrogates — raw or as \uXXXX escapes — are rejected with a ParseError instead of being passed through. Update the adversarial suite to match: - the roundtrip and fully-escaped generators produce only Unicode scalar values (astral pairs still included), and the AdvString shrinker drops whole characters so candidates stay well-formed; - new property: a lone surrogate injected at any position of a hostile string — raw via stringify or spelled as a \uXXXX escape, with escapes/astral pairs/control characters nearby — is always rejected cleanly (parse raises, valid is false, never an abort); - the deterministic surrogate pins now assert rejection for raw, escaped, reversed-pair, and mixed raw/escaped-half spellings, while well-formed pairs (raw or split across two escapes) still parse. Note: "zero literals preserve the sign of zero" requires the parse(-0) fix from #4061 (based on main) and fails until that lands in this branch's history; all other tests are green on wasm-gc, js, and native. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ae138a1 commit ecf2106

1 file changed

Lines changed: 91 additions & 63 deletions

File tree

json/quickcheck_adversarial_test.mbt

Lines changed: 91 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
// Adversarial property-based tests for the json package, complementing
1616
// `quickcheck_test.mbt`:
1717
//
18-
// - strings built from hostile UTF-16 code units (every control character,
19-
// JSON syntax characters, astral pairs, and *lone surrogates*), used both
20-
// as values and as object keys;
18+
// - strings built from hostile UTF-16 sequences (every control character,
19+
// JSON syntax characters, astral pairs), used both as values and as
20+
// object keys;
2121
// - the fully `\uXXXX`-escaped spelling of those strings;
22+
// - *lone surrogates* injected into any position of such strings — raw or
23+
// as `\uXXXX` escapes — which the parser must always reject with a clean
24+
// parse error (strings stay Unicode well-formed) and never abort on;
2225
// - textual zero literals (`-0`, `-0.0e7`, ...) which must preserve the
2326
// IEEE-754 sign of zero;
2427
// - integer literals across the full Int64/UInt64 range, whose text must be
@@ -32,9 +35,10 @@
3235
///|
3336
/// Characters drawn from pools that stress every branch of the escaper and
3437
/// the string lexer: control characters (escaped as `\uXXXX` or short
35-
/// escapes), JSON syntax characters, BMP boundary values, astral code points
36-
/// (surrogate pairs in UTF-16), and lone surrogates, which MoonBit strings
37-
/// can represent and the parser passes through.
38+
/// escapes), JSON syntax characters, BMP boundary values, and astral code
39+
/// points (surrogate pairs in UTF-16). Only Unicode scalar values appear
40+
/// here — MoonBit strings stay Unicode well-formed, so unpaired surrogates
41+
/// are generated separately and asserted to be *rejected* by the parser.
3842
fn adversarial_char_gen() -> @quickcheck.Generator[Char] {
3943
@quickcheck.frequency([
4044
// Every control character U+0000..U+001F.
@@ -52,9 +56,6 @@ fn adversarial_char_gen() -> @quickcheck.Generator[Char] {
5256
'\u{FFFF}', '\u{10000}', '\u{1F600}', '\u{10FFFF}',
5357
]),
5458
),
55-
// Lone surrogates: representable in UTF-16 strings, and the classic
56-
// way to corrupt a JSON stringifier or parser.
57-
(2, @quickcheck.int_range(0xD800, 0xE000).map(i => i.unsafe_to_char())),
5859
// Ordinary ASCII so escapes sit inside unescaped runs.
5960
(3, @quickcheck.char_range('a', 'z')),
6061
])
@@ -76,30 +77,49 @@ impl @quickcheck.Arbitrary for AdvString with fn arbitrary(size, state) {
7677
}
7778

7879
///|
79-
/// Shrinks at the UTF-16 code-unit level (dropping one unit at a time), so a
80-
/// counterexample can minimize to half of a surrogate pair if that is what
81-
/// triggers a failure.
80+
/// Shrinks by dropping one character at a time. Working at the `Char` level
81+
/// keeps every candidate Unicode well-formed, so a shrunk counterexample
82+
/// fails for the same reason as the original instead of tripping the
83+
/// parser's unpaired-surrogate rejection.
8284
impl @shrink.Shrink for AdvString with fn shrink(self) {
83-
let units = self.0.code_units()
84-
let n = units.length()
85+
let chars = self.0.to_array()
86+
let n = chars.length()
8587
if n == 0 {
8688
return Iter::empty()
8789
}
8890
Iter::singleton(AdvString("")).concat(
8991
(0)
9092
.until(n)
9193
.map(i => {
92-
let buf = StringBuilder(size_hint=n - 1)
93-
for j in 0..<n {
94-
if j != i {
95-
buf.write_char(units[j].to_int().unsafe_to_char())
96-
}
97-
}
98-
AdvString(buf.to_string())
94+
let copy = chars.copy()
95+
ignore(copy.remove(i))
96+
AdvString(String::from_array(copy))
9997
}),
10098
)
10199
}
102100

101+
///|
102+
/// The fully `\uXXXX`-escaped spelling of a string: every UTF-16 code unit
103+
/// as a hex escape, with the digit case alternating per position.
104+
fn fully_escaped(s : String) -> String {
105+
let hex_lower = "0123456789abcdef".to_array()
106+
let hex_upper = "0123456789ABCDEF".to_array()
107+
let buf = StringBuilder()
108+
buf.write_char('"')
109+
for i, unit in s.code_units() {
110+
let code = unit.to_int()
111+
let hex = if i % 2 == 0 { hex_lower } else { hex_upper }
112+
buf.write_char('\\')
113+
buf.write_char('u')
114+
buf.write_char(hex[(code >> 12) & 0xF])
115+
buf.write_char(hex[(code >> 8) & 0xF])
116+
buf.write_char(hex[(code >> 4) & 0xF])
117+
buf.write_char(hex[code & 0xF])
118+
}
119+
buf.write_char('"')
120+
buf.to_string()
121+
}
122+
103123
///|
104124
test "adversarial strings roundtrip as values and as object keys" {
105125
@quickcheck.check((input : (AdvString, AdvString, Int, Bool)) => {
@@ -113,26 +133,35 @@ test "adversarial strings roundtrip as values and as object keys" {
113133
///|
114134
/// Spells every UTF-16 code unit of the string as a `\uXXXX` escape
115135
/// (alternating hex-digit case) and checks the parser reassembles the exact
116-
/// original string — including surrogate pairs split across two escapes and
117-
/// lone surrogates.
136+
/// original string — including surrogate pairs split across two escapes.
118137
test "fully \\uXXXX-escaped strings parse back to the original" {
119-
let hex_lower = "0123456789abcdef".to_array()
120-
let hex_upper = "0123456789ABCDEF".to_array()
121138
@quickcheck.check((s : AdvString) => {
122-
let buf = StringBuilder()
123-
buf.write_char('"')
124-
for i, unit in s.0.code_units() {
125-
let code = unit.to_int()
126-
let hex = if i % 2 == 0 { hex_lower } else { hex_upper }
127-
buf.write_char('\\')
128-
buf.write_char('u')
129-
buf.write_char(hex[(code >> 12) & 0xF])
130-
buf.write_char(hex[(code >> 8) & 0xF])
131-
buf.write_char(hex[(code >> 4) & 0xF])
132-
buf.write_char(hex[code & 0xF])
139+
@json.parse(fully_escaped(s.0)) == Json::string(s.0)
140+
})
141+
}
142+
143+
///|
144+
/// A lone surrogate — raw or spelled as a `\uXXXX` escape — injected at any
145+
/// position of an otherwise hostile string must always be rejected with a
146+
/// clean parse error (`parse` raises, `valid` is false, nothing aborts):
147+
/// parsed strings stay Unicode well-formed. The surrounding prefix/suffix
148+
/// supply nearby escapes, astral pairs, and control characters, exercising
149+
/// both the escape-free fast path and the slow path of the string lexer.
150+
test "lone surrogates are rejected in every position" {
151+
@quickcheck.check((input : (AdvString, AdvString, Int, Bool)) => {
152+
let (prefix, suffix, raw_unit, escape_spelling) = input
153+
let unit = 0xD800 + wrap_index(raw_unit, 0x800)
154+
let lone = String::from_array([unit.unsafe_to_char()])
155+
let content = prefix.0 + lone + suffix.0
156+
let text = if escape_spelling {
157+
fully_escaped(content)
158+
} else {
159+
// `stringify` writes the lone surrogate raw; prefix/suffix contribute
160+
// short escapes and `\uXXXX` escapes when they contain control or
161+
// quote characters.
162+
Json::string(content).stringify()
133163
}
134-
buf.write_char('"')
135-
@json.parse(buf.to_string()) == Json::string(s.0)
164+
parse_succeeds(text) == false && @json.valid(text) == false
136165
})
137166
}
138167

@@ -322,31 +351,30 @@ test "default nesting limit boundary at depth 1024" {
322351

323352
///|
324353
/// Deterministic pins for the surrogate cases the properties above explore
325-
/// randomly, so a regression shows up with a readable diff.
354+
/// randomly, so a regression shows up with a readable diff. Parsed strings
355+
/// stay Unicode well-formed: every unpaired surrogate — raw or escaped — is
356+
/// a clean parse error, never an abort and never an ill-formed string.
326357
test "surrogate handling pins" {
327-
// A lone high surrogate roundtrips raw.
328-
let lone = String::from_array([(0xD800).unsafe_to_char()])
329-
let json = Json::string(lone)
330-
assert_true(@json.parse(json.stringify()) == json)
331-
// Its escaped spelling parses to the same string.
332-
assert_true(@json.parse("\"\\uD800\"") == json)
333-
// An escaped surrogate pair reassembles to the astral character.
334-
assert_true(@json.parse("\"\\uD83D\\uDE00\"") == Json::string("\u{1F600}"))
335-
// A reversed pair (low then high) roundtrips as two lone surrogates.
336-
let reversed = String::from_array([
337-
(0xDC00).unsafe_to_char(),
338-
(0xD800).unsafe_to_char(),
339-
])
340-
let reversed_json = Json::string(reversed)
341-
assert_true(@json.parse(reversed_json.stringify()) == reversed_json)
342-
// A raw lone trailing surrogate combined with an escape used to abort the
343-
// slow-path string lexer: `flush` sliced with the checked `[start:end]`,
344-
// which panics when the code unit at a boundary is a trailing surrogate.
358+
let lone_high = String::from_array([(0xD800).unsafe_to_char()])
345359
let lone_low = String::from_array([(0xDC00).unsafe_to_char()])
346-
assert_true(
347-
@json.parse("\"" + lone_low + "\\n\"") == Json::string(lone_low + "\n"),
348-
)
349-
assert_true(
350-
@json.parse("\"\\n" + lone_low + "\"") == Json::string("\n" + lone_low),
351-
)
360+
// Raw lone surrogates, escape-free (fast path).
361+
assert_false(@json.valid("\"" + lone_high + "\""))
362+
assert_false(@json.valid("\"" + lone_low + "\""))
363+
// A reversed pair (low then high) is two unpaired surrogates.
364+
assert_false(@json.valid("\"" + lone_low + lone_high + "\""))
365+
// Raw lone surrogates next to escapes (slow path; used to abort the
366+
// process via checked slicing in `flush`).
367+
assert_false(@json.valid("\"" + lone_low + "\\n\""))
368+
assert_false(@json.valid("\"\\n" + lone_low + "\""))
369+
assert_false(@json.valid("\"" + lone_high + "\\t\""))
370+
// Escaped lone surrogates.
371+
assert_false(@json.valid("\"\\uD800\""))
372+
assert_false(@json.valid("\"\\uDC00\""))
373+
assert_false(@json.valid("\"\\uD800\\uD800\""))
374+
// Mixed raw/escaped halves do not pair up.
375+
assert_false(@json.valid("\"\\uD800" + lone_low + "\""))
376+
assert_false(@json.valid("\"" + lone_high + "\\uDC00\""))
377+
// Well-formed pairs still parse, raw or escaped.
378+
assert_true(@json.parse("\"\\uD83D\\uDE00\"") == Json::string("\u{1F600}"))
379+
assert_true(@json.parse("\"\u{1F600}\"") == Json::string("\u{1F600}"))
352380
}

0 commit comments

Comments
 (0)