Accept surrogates in _json.encode_basestring{,_ascii} - #7673
Conversation
📝 WalkthroughWalkthroughJSON string encoding switched from Rust UTF-8 ( Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
📦 Library DependenciesThe following Lib/ modules were modified. Here are their dependencies: [ ] lib: cpython/Lib/json dependencies:
dependent tests: (10 tests)
Legend:
|
There was a problem hiding this comment.
🧹 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_idxadvances (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+3for lone surrogates is safe because WTF-8 encodes every surrogate as exactly 3 bytes. Non-BMP scalars correctly emit a UTF-16 surrogate pair viaencode_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'spy_encode_basestringbehavior (including not escaping0x7F).Optional readability nit: line 112 re-calls
wtf8.as_bytes().iter()whilebytesis 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 — checkedWtf8Buf::from_bytesreplaces UB-pronefrom_utf8_unchecked.The invariant comment accurately describes what
write_json_stringnow guarantees, and switching to the checked constructor turns any accidental violation of that invariant into a clean panic rather than UB. The flush slices inwrite_json_stringare 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:
Wtf8Bufis imported at line 17 butWtf8is referenced via its full path here; importingWtf8alongsideWtf8Bufwould 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
⛔ Files ignored due to path filters (1)
Lib/test/test_json/test_unicode.pyis excluded by!Lib/**
📒 Files selected for processing (2)
crates/stdlib/src/json.rscrates/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.
aeaae1e to
1a889a3
Compare
There was a problem hiding this comment.
🧹 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:
- Line 102: the hardcoded
3for the WTF-8 surrogate length works, butCodePoint::len_wtf8()already exists and is used elsewhere in this file (line 150). Using it makes the intent self-documenting.- Line 112:
bytesis already bound at line 78; re-derivingwtf8.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
⛔ Files ignored due to path filters (1)
Lib/test/test_json/test_unicode.pyis excluded by!Lib/**
📒 Files selected for processing (2)
crates/stdlib/src/json.rscrates/stdlib/src/json/machinery.rs
thanks for the review ! |
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)).
|
Thank you! |
Thanks for the review :) |
|
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 |
Summary
json.dumpsraisedUnicodeEncodeErroron any Pythonstrcontaining lone surrogate code points (U+D800–U+DFFF), even though CPython accepts such strings and emits them either as\uXXXXescapes (ensure_ascii=True) or as raw WTF-8 bytes (ensure_ascii=False).Root cause was the declared argument type of
_json.encode_basestringand_json.encode_basestring_ascii. Both tookPyUtf8StrRef, which forces aPython str → &strconversion that rejects surrogates. The same file'sscanstringalready 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
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_CHARStypos fixed in the same commit.Test suite
0 failures, 0 unexpected successes.
Pre-push
cargo fmt --all --checkcleancargo clippy -p rustpython-stdlib --all-targets -- -D warningscleanScope
Decoder path (
JSONDecoderscanner,scan_once, severalpystr: PyUtf8StrRefentry points) is unchanged in this PR. That is a separate audit, deferred to a follow-up.test_single_surrogate_decoderemains skipped with an updated reason.Summary by CodeRabbit
Bug Fixes
Improvements