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
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.
3842fn 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.
8284impl @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///|
104124test " 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.
118137test " 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.
326357test "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