Skip to content

Accept surrogates in _json.encode_basestring{,_ascii} - #7673

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

Accept surrogates in _json.encode_basestring{,_ascii}#7673
youknowone merged 1 commit into
RustPython:mainfrom
changjoon-park:fix-json-surrogate-dumps

Conversation

@changjoon-park

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

Copy link
Copy Markdown
Contributor

Summary

json.dumps raised UnicodeEncodeError on any Python str containing lone surrogate code points (U+D800–U+DFFF), even though CPython accepts such strings and emits them either as \uXXXX escapes (ensure_ascii=True) or as raw WTF-8 bytes (ensure_ascii=False).

Root cause was the declared argument type of _json.encode_basestring and _json.encode_basestring_ascii. Both took PyUtf8StrRef, which forces a Python str → &str conversion that rejects surrogates. The same file's scanstring already uses the correct pattern (PyStrRef + s.as_wtf8()).

See the commit message for the detailed per-file breakdown.

Verification

CPython 3.13.4 byte-identical parity

Test Cases Result
Targeted manual probes 16 All byte-identical
Random fuzz (seed=42) 10,000 strings × 2 modes All byte-identical

Fuzz generator sampled across ASCII (25%), BMP pre-surrogate (20%), surrogate range (20%), BMP post-surrogate (20%), and non-BMP (15%) code points. Exposed the two pre-existing ESCAPE_CHARS typos fixed in the same commit.

Test suite

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

0 failures, 0 unexpected successes.

Pre-push

  • cargo fmt --all --check clean
  • cargo clippy -p rustpython-stdlib --all-targets -- -D warnings clean

Scope

Decoder path (JSONDecoder scanner, scan_once, several pystr: PyUtf8StrRef entry points) is unchanged in this PR. That is a separate audit, deferred to a follow-up. test_single_surrogate_decode remains skipped with an updated reason.

Summary by CodeRabbit

  • Bug Fixes

    • Corrected control-character escaping so JSON output no longer mis-escapes edge-case bytes.
    • Fixed escape mappings for previously mishandled characters.
  • Improvements

    • JSON string encoding now preserves non-standard Unicode sequences (including lone surrogates) instead of corrupting them.
    • Public JSON encoding functions produce encoding results that safely retain original byte sequences and behave correctly in ASCII-only vs. full-Unicode modes.

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

JSON string encoding switched from Rust UTF-8 (&str/String) to WTF-8 (&Wtf8/Wtf8Buf): public Python-facing functions and internal helpers now use WTF-8 types, write_json_string updated to traverse WTF-8 bytes and handle surrogate code points and corrected control escape mappings.

Changes

Cohort / File(s) Summary
Public API / encoder entrypoints
crates/stdlib/src/json.rs
Public functions encode_basestring and encode_basestring_ascii now accept PyStrRef and return Wtf8Buf. encode_string signature changed from (&str)->String to (&Wtf8)->Wtf8Buf. Unsafe String::from_utf8_unchecked removed in favor of Wtf8Buf::from_bytes(...).
Core encoding logic
crates/stdlib/src/json/machinery.rs
write_json_string now accepts &Wtf8 and iterates using WTF-8-aware indices; ASCII-only path uses code_point_indices() and emits \u{XXXX} for non-BMP scalars, lone surrogates emitted as single \uXXXX using WTF-8 code point values. Non-ASCII mode only escapes required JSON chars and control bytes. ESCAPE_CHARS table corrections: 0x0b -> \u000b, 0x1b -> \u001b. Function signature updated accordingly.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through bytes both odd and neat,

WTF-8 now hums beneath my feet.
Surrogates bowed and escapes set right,
No unsafe leaps kept me up at night.
JSON sings in a softer, safer beat.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main change: accepting surrogates in the _json.encode_basestring functions, which is the core bug fix addressed in this PR.
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

github-actions Bot commented Apr 24, 2026

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: 11)

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 (2)
crates/stdlib/src/json/machinery.rs (1)

75-128: Rewrite looks correct and well-documented.

Both branches preserve WTF-8 validity on the output:

  • ascii_only: all write_start_idx advances (idx+1, idx+c.len_utf8(), idx+3) land on code-point boundaries, so every &bytes[write_start_idx..idx] flush is WTF-8 aligned. The fixed +3 for lone surrogates is safe because WTF-8 encodes every surrogate as exactly 3 bytes. Non-BMP scalars correctly emit a UTF-16 surrogate pair via encode_utf16.
  • non-ascii: the only escaped targets (< 0x20, \\, ") are all < 0x80, which never appear inside a multi-byte WTF-8 sequence, so byte-level iteration never splits a codepoint. Multi-byte scalars and 3-byte lone-surrogate sequences pass through the trailing flush untouched — matching CPython's py_encode_basestring behavior (including not escaping 0x7F).

Optional readability nit: line 112 re-calls wtf8.as_bytes().iter() while bytes is already bound at line 78.

Optional tidy-up
-        for (idx, c) in wtf8.as_bytes().iter().enumerate() {
-            let escaped_opt: Option<&'static str> = match *c {
+        for (idx, &c) in bytes.iter().enumerate() {
+            let escaped_opt: Option<&'static str> = match c {
                 x if x < 0x20 => Some(ESCAPE_CHARS[x as usize]),
                 b'\\' => Some("\\\\"),
                 b'\"' => Some("\\\""),
                 _ => None,
             };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/stdlib/src/json/machinery.rs` around lines 75 - 128, The non-ascii
branch unnecessarily calls wtf8.as_bytes().iter() again even though bytes is
already bound; update the loop in write_json_string (function write_json_string)
to iterate over bytes.iter().enumerate() (or bytes.iter().enumerate()) instead
of wtf8.as_bytes().iter().enumerate() to avoid the redundant call while
preserving the existing logic and use of idx, c, escaped_opt, write_start_idx,
and the trailing flush behavior.
crates/stdlib/src/json.rs (1)

670-684: Solid safety upgrade — checked Wtf8Buf::from_bytes replaces UB-prone from_utf8_unchecked.

The invariant comment accurately describes what write_json_string now guarantees, and switching to the checked constructor turns any accidental violation of that invariant into a clean panic rather than UB. The flush slices in write_json_string are always WTF-8 aligned (code-point boundaries in the ASCII-only path; ASCII-only escape triggers in the non-ASCII path), so this should never actually panic in practice.

Optional nit: Wtf8Buf is imported at line 17 but Wtf8 is referenced via its full path here; importing Wtf8 alongside Wtf8Buf would be a touch more consistent.

Optional refactor
-    use rustpython_common::wtf8::Wtf8Buf;
+    use rustpython_common::wtf8::{Wtf8, Wtf8Buf};
-    fn encode_string(wtf8: &rustpython_common::wtf8::Wtf8, ascii_only: bool) -> Wtf8Buf {
+    fn encode_string(wtf8: &Wtf8, ascii_only: bool) -> Wtf8Buf {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/stdlib/src/json.rs` around lines 670 - 684, The code correctly
replaces unsafe from_utf8_unchecked with Wtf8Buf::from_bytes in encode_string,
but for consistency import Wtf8 alongside Wtf8Buf instead of referencing
rustpython_common::wtf8::Wtf8 by full path; update the top-of-file use list to
include Wtf8 (so encode_string can refer to Wtf8 directly) while leaving the
logic in encode_string and the call to machinery::write_json_string unchanged.
🤖 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`:
- Around line 670-684: The code correctly replaces unsafe from_utf8_unchecked
with Wtf8Buf::from_bytes in encode_string, but for consistency import Wtf8
alongside Wtf8Buf instead of referencing rustpython_common::wtf8::Wtf8 by full
path; update the top-of-file use list to include Wtf8 (so encode_string can
refer to Wtf8 directly) while leaving the logic in encode_string and the call to
machinery::write_json_string unchanged.

In `@crates/stdlib/src/json/machinery.rs`:
- Around line 75-128: The non-ascii branch unnecessarily calls
wtf8.as_bytes().iter() again even though bytes is already bound; update the loop
in write_json_string (function write_json_string) to iterate over
bytes.iter().enumerate() (or bytes.iter().enumerate()) instead of
wtf8.as_bytes().iter().enumerate() to avoid the redundant call while preserving
the existing logic and use of idx, c, escaped_opt, write_start_idx, and the
trailing flush behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 74790e66-0552-422d-89ef-cfd3b681a94d

📥 Commits

Reviewing files that changed from the base of the PR and between 43ef2ea and aeaae1e.

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

encode_basestring/encode_basestring_ascii took PyUtf8StrRef, so
json.dumps(str_with_lone_surrogate) raised UnicodeEncodeError at the
Python/Rust boundary before write_json_string ran. CPython's encoder
emits \uXXXX under ensure_ascii=True and passes raw WTF-8 otherwise.

Switch to PyStrRef + s.as_wtf8(), matching scanstring in the same file.
Rewrite write_json_string to accept &Wtf8 and iterate
code_point_indices, emitting \uXXXX for surrogates in ascii mode and
passing their bytes through otherwise. Stop escaping 0x7F in the
ensure_ascii=False path (matches py_encode_basestring). Return Wtf8Buf
via the checked from_bytes so invariant breaks panic instead of UB.

Fuzzing also exposed two pre-existing ESCAPE_CHARS typos: 0x0B was
"\u000" and 0x1B was "\u001" (both missing trailing 'b'). Fixed here.

Verified byte-identical with CPython 3.13.4 over 16 manual + 10,000
random fuzz cases. Full test.test_json: 214 tests, 0 failures, 0
unexpected successes. Unmasks test_ascii_non_printable_encode and
test_single_surrogate_encode. Decoder path is a follow-up.
@changjoon-park
changjoon-park force-pushed the fix-json-surrogate-dumps branch from aeaae1e to 1a889a3 Compare April 24, 2026 14:19

@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/machinery.rs (1)

75-128: Encoder rewrite looks correct; couple of minor readability nits.

The ascii_only / non-ascii_only branches trace correctly for mixed ASCII, escaped, multi-byte scalar, and lone-surrogate inputs. The non-ascii_only byte-scan is also safe: WTF-8 continuation/leading bytes and surrogate encodings are all ≥ 0x80, so 0x22 (") and 0x5C (\) cannot appear inside a multi-byte sequence.

Two small, optional polish items:

  1. Line 102: the hardcoded 3 for the WTF-8 surrogate length works, but CodePoint::len_wtf8() already exists and is used elsewhere in this file (line 150). Using it makes the intent self-documenting.
  2. Line 112: bytes is already bound at line 78; re-deriving wtf8.as_bytes() here is redundant.
♻️ Proposed tidy-ups
             } else {
                 // Lone surrogate code point (U+D800..U+DFFF).
-                // WTF-8 encodes these as 3-byte sequences; skip those raw bytes
-                // and emit a \uXXXX escape with the surrogate value.
+                // WTF-8 encodes surrogates as 3-byte sequences; skip those raw
+                // bytes and emit a \uXXXX escape with the surrogate value.
                 w.write_all(&bytes[write_start_idx..idx])?;
-                write_start_idx = idx + 3;
+                write_start_idx = idx + cp.len_wtf8();
                 write!(w, "\\u{:04x}", cp.to_u32())?;
             }
-        for (idx, c) in wtf8.as_bytes().iter().enumerate() {
+        for (idx, c) in bytes.iter().enumerate() {
             let escaped_opt: Option<&'static str> = match *c {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/stdlib/src/json/machinery.rs` around lines 75 - 128, In
write_json_string: replace the hardcoded surrogate byte length 3 (used in the
lone-surrogate branch that currently does write_start_idx = idx + 3) with the
existing CodePoint::len_wtf8() method (call it on cp) to make intent explicit,
and in the else (ensure_ascii == false) branch avoid re-calling wtf8.as_bytes()
by iterating over the already-bound bytes variable (use bytes.iter().enumerate()
instead of wtf8.as_bytes().iter().enumerate()) so the code reuses the buffer and
removes the redundant call.
🤖 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/machinery.rs`:
- Around line 75-128: In write_json_string: replace the hardcoded surrogate byte
length 3 (used in the lone-surrogate branch that currently does write_start_idx
= idx + 3) with the existing CodePoint::len_wtf8() method (call it on cp) to
make intent explicit, and in the else (ensure_ascii == false) branch avoid
re-calling wtf8.as_bytes() by iterating over the already-bound bytes variable
(use bytes.iter().enumerate() instead of wtf8.as_bytes().iter().enumerate()) so
the code reuses the buffer and removes the redundant call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: e749892a-5645-4162-a1a0-2e86c90310a1

📥 Commits

Reviewing files that changed from the base of the PR and between aeaae1e and 1a889a3.

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

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

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

Copy link
Copy Markdown
Contributor Author

👍

thanks for the review !

youknowone pushed a commit that referenced this pull request Apr 24, 2026
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 #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)).
@youknowone

Copy link
Copy Markdown
Member

Thank you!

@changjoon-park

Copy link
Copy Markdown
Contributor Author

Thank you!

Thanks for the review :)

@changjoon-park

Copy link
Copy Markdown
Contributor Author

Retroactive verification against CPython 3.14.4 (RustPython's stated target version per README): re-ran the encoder fuzz at 20,000 cases (10,000 each for ensure_ascii=True and ensure_ascii=False, seed=42, codepoint distribution covering ASCII / BMP pre-surrogate / surrogate range / BMP post-surrogate / non-BMP). All 20,000 byte-identical with CPython 3.14.4. Original PR body cited 3.13.4 — for completeness, behavior is identical between the two versions for json.dumps. No change to merged code; this is a verification-trail comment only.

@changjoon-park
changjoon-park deleted the fix-json-surrogate-dumps branch April 27, 2026 13:19
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