Skip to content

Accept surrogates in _json.JsonScanner decode path - #7675

Merged
youknowone merged 1 commit into
RustPython:mainfrom
changjoon-park:fix-json-surrogate-loads
Apr 24, 2026
Merged

Accept surrogates in _json.JsonScanner decode path#7675
youknowone merged 1 commit into
RustPython:mainfrom
changjoon-park:fix-json-surrogate-loads

Conversation

@changjoon-park

@changjoon-park changjoon-park commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Summary

json.loads had two distinct failure modes on JSON strings containing lone surrogate code points (U+D800–U+DFFF):

  1. UnicodeEncodeError when the Python str passed to json.loads itself carried a surrogate (e.g., json.loads('"\ud83d"')) — JsonScanner::Callable::call called try_into_utf8 at entry.
  2. Silent U+FFFD corruption when surrogates arrived via JSON \uXXXX escapes inside arrays or dict keys (e.g., json.loads('["\\uD83D"]')['�']) — call_scan_once and parse_object collapsed the scanner's Wtf8Buf output through .to_string(), which routes through impl Display for Wtf8 (intentionally lossy with U+FFFD).

CPython accepts surrogate-bearing strings in both positions and preserves them.

Root-level strings and mid-string surrogates (e.g., "a\uD83Db") already worked because JsonScanner::parse put wtf8_result directly into a tuple, routing through impl ToPyObject for Wtf8Buf which preserves. Surrogate pair combining (😀😀) also worked and is unchanged (handled in machinery::scanstring).

Switch JsonScanner's five PyUtf8StrRef signatures to PyStrRef, drop the try_into_utf8 at Callable::call entry, and feed Wtf8Buf directly to new_str instead of .to_string(). Memo keys change from HashMap<String, PyStrRef> to HashMap<Wtf8Buf, PyStrRef> (Wtf8Buf already implements Hash). parse_number takes &[u8] since JSON numbers are ASCII. machinery::scanstring is unchanged.

This extends the WTF-8 refactor pattern from #7673 to the decoder side.

Verification

Targeted probes

Input Before After CPython
json.loads('"\ud83d"') UnicodeEncodeError '\ud83d' '\ud83d'
json.loads('"\\uD83D"') '\ud83d' '\ud83d' '\ud83d'
json.loads('"\\uD83D\\uDE00"') '😀' '😀' '😀'
json.loads('["\\uD83D"]') ['�'] ['\ud83d'] ['\ud83d']
json.loads('{"\\uD83D": 1}') {'�': 1} {'\ud83d': 1} {'\ud83d': 1}
json.loads('"a\\uD83Db"') 'a\ud83db' 'a\ud83db' 'a\ud83db'

CPython byte-identical fuzz

10,000 random JSON documents containing surrogate escapes at root/list/dict positions. Each decoded in both RustPython and CPython 3.13.4, then re-encoded via json.dumps(obj, ensure_ascii=True, sort_keys=True) to canonical bytes. All 10,000 byte-identical. Seed 42.

Sampled codepoint distribution: ASCII 30%, BMP pre-surrogate 20%, surrogate range 20%, BMP post-surrogate 20%, non-BMP 10%. Surrogate-pair escapes combine per RFC 8259 (handled in machinery::scanstring).

Additional coverage

Six strict=False mode cases mixing surrogates with control characters (\x00, \x01, \t): all byte-identical with CPython.

Twelve decode-error position cases with surrogates preceding the error site: eleven byte-identical with CPython. One pre-existing position off-by-one on "\\uG000" (invalid \u escape in machinery::scanstring) is outside this PR's scope.

Memo cache semantics verified: repeated surrogate-bearing keys in the same document resolve correctly, distinct surrogate keys in one dict remain distinct, duplicate-key "last wins" preserved.

Test suite

$ ./target/release/rustpython -m unittest test.test_json
Ran 214 tests in 105s
OK (skipped=3, expected failures=12)

test_single_surrogate_decode skip override removed — now passes directly. No regressions.

Pre-push

  • cargo fmt --all --check clean
  • cargo clippy -p rustpython-stdlib --all-targets -- -D warnings clean
  • prek run --all-files — all hooks pass (merge conflicts, ruff format, redundant-test-patches, rustfmt, cspell, prettier, ruff check, opcode metadata)

Scope

Decoder only. Encoder side was addressed in #7673. Skip marker removed from Lib/test/test_json/test_unicode.py::TestCUnicode::test_single_surrogate_decode.

Summary by CodeRabbit

  • Bug Fixes

    • Improved JSON parsing to correctly handle edge cases with special characters and string encoding.
  • Refactor

    • Optimized JSON scanning operations for enhanced performance in string, number, and object processing.

The _json decoder had two failure modes when a Python str value would
contain a lone surrogate (legal per the Python 3 str model):

1. Boundary UnicodeEncodeError: JsonScanner::Callable::call rejected
   any input str with surrogates via try_into_utf8 before scanning
   began.
2. Silent U+FFFD corruption: call_scan_once and parse_object's key
   path called .to_string() on scanstring's Wtf8Buf output, which
   routes through Wtf8::Display (lossy). Array values and dict keys
   decoded from JSON \uXXXX escapes silently became U+FFFD.

Switch JsonScanner's five PyUtf8StrRef signatures to PyStrRef, drop
the entry-point try_into_utf8 call, and feed Wtf8Buf directly to
new_str instead of going through .to_string(). Key memoization now
uses HashMap<Wtf8Buf, PyStrRef> so surrogate-bearing keys survive
interning. parse_number takes &[u8] since JSON numbers are ASCII.

Extends the WTF-8 refactor pattern established in RustPython#7673 to the
decoder. machinery::scanstring already returns Wtf8Buf and is
unchanged.

Unmasks test_single_surrogate_decode. 214 tests in test.test_json
pass with no regressions. Decoder output verified byte-identical to
CPython 3.13.4 over 10,000 random fuzz cases (JSON docs containing
random surrogate escapes at root/list/dict positions, compared via
json.dumps(..., ensure_ascii=True, sort_keys=True)).
@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

JSON parsing logic refactored to use WTF-8 representations instead of UTF-8, enabling preservation of lone surrogates. Byte-level operations replace string operations for number/literal parsing, and object memoization now uses Wtf8Buf keys to avoid lossy conversions.

Changes

Cohort / File(s) Summary
JSON Parsing Refactor
crates/stdlib/src/json.rs
Migrated JSON scanner from UTF-8 (PyUtf8StrRef) to WTF-8 (PyStrRef) representation. Number parsing now operates on &[u8] slices. Object/array memoization changed from HashMap<String, ...> to HashMap<Wtf8Buf, ...>. String iteration now uses wtf8.code_point_indices() instead of s.char_indices(). String creation via vm.ctx.new_str(wtf8_result) to preserve surrogates during object key interning.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested reviewers

  • youknowone

Poem

🐰 A JSON hop through WTF-8's dance,
Where surrogates get their second chance,
Bytes over strings, we parse with care,
No lossy maps, just truth laid bare!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Accept surrogates in _json.JsonScanner decode path' directly and specifically describes the main change—enabling JSON decoder to handle UTF-16 surrogate code points without errors or lossy replacements, which aligns with the core objective of the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[ ] lib: cpython/Lib/json
[ ] test: cpython/Lib/test/test_json (TODO: 10)

dependencies:

  • json (native: _json, decoder, encoder, json.tool, sys)
    • _colorize, argparse, codecs, re

dependent tests: (10 tests)

  • json: test_logging test_plistlib test_subprocess test_sysconfig test_tomllib test_tools test_traceback test_zoneinfo
    • importlib.metadata: test_importlib
    • multiprocessing.resource_tracker: test_concurrent_futures

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/stdlib/src/json.rs (1)

238-238: Memoization on Wtf8Buf correctly preserves lone surrogates in keys and string values — nice fix.

Both the HashMap<Wtf8Buf, PyStrRef> memo and the direct vm.ctx.new_str(wtf8_result) (instead of .to_string()) avoid the lossy U+FFFD collapse path for scanstring output. Keying on the WTF-8 bytes also keeps memo hits identical to the source key under surrogate-aware equality.

♻️ Optional micro-refactor: avoid one `Wtf8Buf` clone using `Entry`

The cache-miss path clones key_wtf8 once to insert and once (implicitly) to stash the str. You can collapse that to a single clone using entry:

-                let key: PyObjectRef = match memo.get(&key_wtf8) {
-                    Some(cached) => cached.clone().into(),
-                    None => {
-                        let py_key = vm.ctx.new_str(key_wtf8.clone());
-                        memo.insert(key_wtf8, py_key.clone());
-                        py_key.into()
-                    }
-                };
+                let key: PyObjectRef = memo
+                    .entry(key_wtf8.clone())
+                    .or_insert_with_key(|k| vm.ctx.new_str(k.clone()))
+                    .clone()
+                    .into();

Not a correctness concern — keys are typically short — so feel free to defer.

Also applies to: 289-296, 400-400, 517-517, 544-544

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/stdlib/src/json.rs` at line 238, The memoization currently inserts
into memo: &mut HashMap<Wtf8Buf, PyStrRef> by cloning key_wtf8 twice on a cache
miss; change the insertion to use HashMap::entry for memo so you can take
ownership of key_wtf8 once and either return the existing PyStrRef or insert
vm.ctx.new_str(wtf8_result) without a second clone. Locate the cache-miss path
where key_wtf8 and vm.ctx.new_str are used (scanstring result handling) and
replace the separate contains/insert flow with entry(key_wtf8).or_insert_with(||
vm.ctx.new_str(wtf8_result)) to avoid the extra Wtf8Buf clone while preserving
surrogate-aware keys and values.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@crates/stdlib/src/json.rs`:
- Line 238: The memoization currently inserts into memo: &mut HashMap<Wtf8Buf,
PyStrRef> by cloning key_wtf8 twice on a cache miss; change the insertion to use
HashMap::entry for memo so you can take ownership of key_wtf8 once and either
return the existing PyStrRef or insert vm.ctx.new_str(wtf8_result) without a
second clone. Locate the cache-miss path where key_wtf8 and vm.ctx.new_str are
used (scanstring result handling) and replace the separate contains/insert flow
with entry(key_wtf8).or_insert_with(|| vm.ctx.new_str(wtf8_result)) to avoid the
extra Wtf8Buf clone while preserving surrogate-aware keys and values.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: b72b51c1-2c59-48d1-992e-5fad24ac84e1

📥 Commits

Reviewing files that changed from the base of the PR and between 2e5c2be and b7a94a9.

⛔ Files ignored due to path filters (1)
  • Lib/test/test_json/test_unicode.py is excluded by !Lib/**
📒 Files selected for processing (1)
  • crates/stdlib/src/json.rs

@youknowone
youknowone merged commit fb1218d into RustPython:main Apr 24, 2026
21 checks passed
@changjoon-park

Copy link
Copy Markdown
Contributor Author

Re-verified against CPython 3.14.4 (RustPython's stated target version): same 10,000-case canonical-form fuzz produces 0 mismatches. Original PR body cited 3.13.4 because that was the system Python at probe time — for completeness, results are identical between the two versions for json decoder behavior (no relevant API changes between 3.13 and 3.14).

@changjoon-park
changjoon-park deleted the fix-json-surrogate-loads branch April 27, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants