test(json): adversarial QuickCheck roundtrip and parser-robustness tests - #4045
test(json): adversarial QuickCheck roundtrip and parser-robustness tests#4045bobzhang wants to merge 3 commits into
Conversation
Coverage Report for CI Build 6069Warning No base build found for commit Coverage: 90.663%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
There was a problem hiding this comment.
Pull request overview
This PR strengthens the json package’s correctness by fixing two parser edge cases uncovered via adversarial property testing (lone-surrogate handling in string lexing and -0 sign preservation in number lexing), and adds a new suite of deterministic, CI-friendly QuickCheck properties to prevent regressions.
Changes:
- Fix
lex_string_slowto avoid aborting on lone trailing-surrogate boundaries by using bounds-checkedview(...)instead of checked slicing. - Fix integer fast-path number lexing to preserve IEEE-754 negative zero by applying the sign after
Int64 -> Doubleconversion. - Add adversarial property tests (strings/escapes/whitespace/dup keys/mutations/nesting limit) plus a targeted unit test for negative-zero parsing.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| json/quickcheck_adversarial_test.mbt | New adversarial QuickCheck suite exercising parser/stringifier robustness and edge cases. |
| json/lex_string.mbt | Prevents aborts when slow-path string flushing crosses lone-surrogate boundaries. |
| json/lex_number.mbt | Preserves -0.0 sign in the integer fast path by negating after conversion to Double. |
| json/lex_number_test.mbt | Adds a regression unit test asserting negative-zero sign preservation across spellings/underflow. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let deep_array = "[".repeat(1024) + "0" + "]".repeat(1024) | ||
| assert_true(@json.valid(deep_array)) | ||
| fn outcome(text : String) -> String { | ||
| try { | ||
| ignore(@json.parse(text)) | ||
| "parsed" | ||
| } catch { | ||
| DepthLimitExceeded => "depth limit" | ||
| _ => "other error" | ||
| } | ||
| } | ||
|
|
||
| let deeper_array = "[".repeat(1025) + "0" + "]".repeat(1025) | ||
| assert_eq(outcome(deeper_array), "depth limit") | ||
| let deep_object = "{\"k\":".repeat(1025) + "0" + "}".repeat(1025) | ||
| assert_eq(outcome(deep_object), "depth limit") |
There was a problem hiding this comment.
Fixed in 29009f6: the test now asserts depth-1024 documents parse successfully for both arrays and objects (assert_eq(outcome(deep_array), "parsed") / assert_eq(outcome(deep_object), "parsed")), and the 1025-deep variables are renamed too_deep_array / too_deep_object. The test now pins the documented boundary exactly on both sides for both shapes.
| ///| | ||
| /// Deleting or replacing one code unit of a valid document must keep the | ||
| /// parser total: it either succeeds or raises a parse error (`valid` agrees | ||
| /// with `parse`), and when the mutant still parses, the parsed value is a | ||
| /// fixed point of restringify-and-reparse. | ||
| test "parse stays total under single code-unit deletion and replacement" { |
There was a problem hiding this comment.
Fixed in 29009f6 by taking the stronger option: the test now mutates genuine UTF-16 code units via code_units() instead of Char-level to_array(). Deletion/replacement can now split an astral surrogate pair, and the replacement unit is drawn from the full 16-bit range (including lone surrogates). Verified green on wasm-gc, js, and native.
8360ba6 to
fc24c9c
Compare
6af3721 to
5cf2ae6
Compare
29009f6 to
c217fa5
Compare
15d2ac4 to
b93a00e
Compare
c217fa5 to
10d4e23
Compare
ca31837 to
41233d9
Compare
New property families complementing json/quickcheck_test.mbt: - adversarial strings (every control character, JSON syntax characters, BMP boundaries, astral pairs, and lone surrogates) roundtrip through stringify/parse as both values and object keys, across indent and escape_slash options; - the fully \uXXXX-escaped spelling of those strings parses back to the exact original, including surrogate pairs split across two escapes and lone-surrogate escapes; - every textual spelling of zero preserves the sign of zero bitwise; - Int64/UInt64 literals roundtrip textually (repr preserved past 2^53); - random legal whitespace inserted between tokens never changes the parsed value; - duplicate object keys: last occurrence wins; - single code-unit deletions/replacements of valid documents keep the parser total (parse agrees with valid, no panics) and successfully parsed mutants are fixed points of restringify-and-reparse; - deterministic pins for the default 1024 nesting-depth boundary and for surrogate handling. The AdvString generator shrinks at the UTF-16 code-unit level so counterexamples can minimize to half of a surrogate pair. These properties found the two parser bugs fixed in the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tate at code-unit level - The nesting-limit test now asserts that documents exactly 1024 levels deep parse successfully for BOTH arrays and objects (previously it only checked valid() for the array and only checked the 1025-deep failure for objects), and the 1025-deep variables are renamed too_deep_array / too_deep_object to reflect their depth. - The mutation-totality test now genuinely mutates single UTF-16 code units via code_units() instead of Char-level to_array(), so deleting or replacing a unit can split an astral surrogate pair, and the replacement unit is drawn from the full 16-bit range (including lone surrogates) — strictly stronger fuzzing that matches the test's name and doc comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e 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>
10d4e23 to
f9a3219
Compare
Summary
Adversarial QuickCheck property tests for the
jsonparser and stringifier, complementingjson/quickcheck_test.mbt. This suite found the bugs now tracked issue-first and fixed in dedicated PRs:main)\uXXXXsurrogate escapes manufactured ill-formed strings (fixed in fix(json): reject unpaired \uXXXX surrogate escapes in strings #4064, basemain)parse("-0")lost the sign of zero (fixed in fix(json): preserve the sign of -0 in the integer fast path #4061, already merged)Merge order: this PR should merge after BOTH #4056 and #4064. It is based on
agent/fix-json-lexer(#4056) and retargets tomainwhen that merges. On the current branch (which has #4056's fix and the merged #4061, but not #4064) exactly two tests fail, both needing #4064:"lone surrogates are rejected in every position"— the escaped (\uXXXX) spelling of a lone surrogate is still accepted without fix(json): reject unpaired \uXXXX surrogate escapes in strings #4064;"surrogate handling pins"— the escaped-rejection assertions.All other 209 tests are green on wasm-gc, js, and native; the suite goes fully green once #4064 is in the base history.
New property families (
json/quickcheck_adversarial_test.mbt)indent/escape_slash. TheAdvStringshrinker drops whole characters so candidates stay well-formed.\uXXXX-escaped spellings parse back to the exact original (pairs split across two escapes, mixed-case hex).stringifyor spelled as an escape, with escapes/astral pairs/control chars nearby — is always rejected cleanly:parseraises the documented error,validis false, nothing ever aborts. Deterministic pins cover raw, escaped, reversed-pair, and mixed raw/escaped-half spellings, plus still-accepted well-formed pairs.reprpreserved beyond 2^53).parseconsistent withvalid, successful mutants are restringify-reparse fixed points); depth-1024 boundary pins.Verification
moon checkclean;moon info && moon fmt— no.mbtichanges.🤖 Generated with Claude Code