Skip to content

fix: std.parseJson replaces lone surrogates with U+FFFD instead of corrupting to '?' - #1112

Closed
He-Pin wants to merge 1 commit into
databricks:masterfrom
He-Pin:fix-parsejson-lone-surrogate
Closed

fix: std.parseJson replaces lone surrogates with U+FFFD instead of corrupting to '?'#1112
He-Pin wants to merge 1 commit into
databricks:masterfrom
He-Pin:fix-parsejson-lone-surrogate

Conversation

@He-Pin

@He-Pin He-Pin commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Motivation

std.parseJson keeps unpaired UTF-16 surrogates in the parsed string, but every UTF-8 output path then replaces the unencodable unit with ?:

std.parseJson('"\\ud800"') output bytes
sjsonnet (before) 22 3f 22"?"
sjsonnet (after) 22 ef bf bd 22"\uFFFD"
go-jsonnet v0.22.0 22 ef bf bd 22"\uFFFD"
jrsonnet errors
CPython / JS preserve the surrogate

No implementation emits ?. sjsonnet already follows the U+FFFD policy elsewhere — std.char and %c replace surrogate codepoints with U+FFFD (StringModule.scala, Format.scala), and UnicodeHandlingTests documents that alignment — so std.parseJson contradicted the project's own convention and silently corrupted data on output.

Modification

ValVisitor (used by std.parseJson and std.parseYaml) now sanitizes string values and object keys through a new ValVisitor.replaceLoneSurrogates helper:

  • fast-path scan returns the input unchanged, without allocation, when it contains no surrogates (the overwhelmingly common case);
  • otherwise each unpaired high/low surrogate is replaced with U+FFFD while valid surrogate pairs are preserved.

Result

  • std.parseJson('"\\ud800"') == "\ufffd" — byte-identical with go-jsonnet.
  • Valid pairs unchanged: std.parseJson('"\\ud83d\\ude00"') still yields the emoji.
  • Doubled/mid-string lone surrogates handled ("\ud800\ud800" → two U+FFFD, "a\udc00b""a\ufffdb").
  • Object keys sanitized: std.parseJson('{"\\ud800": 1}')["\ufffd"] == 1.

JSON imports unaffected: the .json import path uses ujson's byte parser, which already drops (in values) or rejects (in keys) lone surrogates before any visitor runs — pre-existing behavior, unchanged by this fix.

Test plan

  • New: new_test_suite/parseJson_lone_surrogate.jsonnet (+ golden) covering lone high, lone low, doubled, mid-string, valid-pair preservation, and key sanitization
  • ./mill 'sjsonnet.jvm[_].test' — all Scala versions (2.12.21 / 2.13.18 / 3.3.8) pass
  • ./mill 'sjsonnet.js[_].compile' 'sjsonnet.native[_].compile' 'sjsonnet.wasm[_].compile' — pass
  • ./mill __.checkFormat — clean

Found by four-way differential testing (sjsonnet vs go-jsonnet vs jrsonnet vs spec).

…rrupting to '?'

Motivation:
std.parseJson kept unpaired UTF-16 surrogates in the parsed string
(like CPython/JS), but every UTF-8 output path then replaced the
unencodable unit with '?': std.parseJson('"\\ud800"') rendered as "?"
while go-jsonnet renders the Unicode replacement character U+FFFD.
No implementation emits '?', and sjsonnet already follows the U+FFFD
policy elsewhere (std.char and %c replace surrogate codepoints with
U+FFFD; UnicodeHandlingTests documents the alignment), so parseJson
contradicted the project's own convention and silently lost data.

Modification:
Sanitize strings in ValVisitor.visitString and object keys in
visitKeyValue via a new ValVisitor.replaceLoneSurrogates helper:
a fast-path scan that returns the input unchanged without allocation
when it contains no surrogates, and otherwise replaces each unpaired
high/low surrogate with U+FFFD while preserving valid pairs.

Result:
std.parseJson('"\\ud800"') == "\ufffd", byte-identical output with
go-jsonnet (22 ef bf bd 22). Valid surrogate pairs pass through
unchanged. The JSON import path (ujson ByteParser) is unaffected: it
already drops or rejects lone surrogates before any visitor runs
(pre-existing behavior, unchanged by this fix).

References:
Found by four-way differential testing (sjsonnet vs go-jsonnet vs
jrsonnet vs spec).
@He-Pin
He-Pin marked this pull request as draft August 6, 2026 03:34
@He-Pin

He-Pin commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Duplicate of #1085, which I missed before opening this — apologies for the noise.

The two PRs make opposite design choices for the same problem:

#1085 #1112 (this one)
behavior reject lone surrogates with an error replace with U+FFFD
matches cpp-jsonnet (error) + jrsonnet (error) — 2 of 3 go-jsonnet — 1 of 3
coverage std.parseJson + strict .json import fast path + Preloader std.parseJson / std.parseYaml via ValVisitor

Deferring to #1085: rejecting aligns with the C++ reference and jrsonnet, and covers the import paths this PR left alone (the ujson ByteParser drops/rejects lone surrogates before the visitor on imports, which #1085's fast-path guard addresses deliberately).

One data point that may be useful for #1085's review: sjsonnet's std.char / %c replace surrogate codepoints with U+FFFD (StringModule.scala, Format.scala, documented in UnicodeHandlingTests) — so with #1085's reject policy, parseJson would be stricter than char/%c. That asymmetry is defensible (parseJson validates input, char/%c construct output) but worth a sentence in the PR or a code comment.

@He-Pin He-Pin closed this Aug 6, 2026
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.

1 participant