fix(render): encode non-BMP characters as UTF-16 surrogate pairs in AsciiJSON

AsciiJSON.Render used fmt.Appendf(buf, "\\u%04x", r) for all non-ASCII
runes. The %04x format is a *minimum* width, not a fixed width, so runes
above U+FFFF (emoji, musical symbols, supplementary CJK, etc.) produce a
5-digit escape like \u1f600. JSON \u escapes are always exactly 4 hex
digits (RFC 8259 §7), so a decoder reads the first 4 digits as a
different character and treats the remaining digit(s) as literal text.
The output is still syntactically valid JSON, which makes the corruption
easy to miss — values silently fail to round-trip.

Fix: detect runes > U+FFFF and split them into a UTF-16 surrogate pair
(\uHHHH\uLLLL) before writing the escape. BMP characters continue to use
the single-escape path unchanged.

Add TestRenderAsciiJSONNonBMP to verify correct surrogate-pair encoding
and full round-trip fidelity for 😀 (U+1F600), 𝄞 (U+1D11E), and 𠀀 (U+20000).

Fixes #4688

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
okxint 2026-09-02 12:29:43 +05:30
parent dcaa4296d1
commit 3498e1cdbc
2 changed files with 55 additions and 2 deletions

View File

@ -160,11 +160,21 @@ func (r AsciiJSON) Render(w http.ResponseWriter) error {
}
var buffer bytes.Buffer
escapeBuf := make([]byte, 0, 6) // Preallocate 6 bytes for Unicode escape sequences
escapeBuf := make([]byte, 0, 12) // 12 bytes for a UTF-16 surrogate pair (\uHHHH\uLLLL)
for _, r := range bytesconv.BytesToString(ret) {
if r > unicode.MaxASCII {
escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x", r) // Reuse escapeBuf
if r > 0xFFFF {
// Non-BMP character: encode as a UTF-16 surrogate pair per RFC 8259 §7.
// \u alone cannot represent code points above U+FFFF (it is always 4 hex digits),
// so we split the rune into a high surrogate and a low surrogate.
r -= 0x10000
high := 0xD800 + (r>>10)&0x3FF
low := 0xDC00 + r&0x3FF
escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x\\u%04x", high, low)
} else {
escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x", r)
}
buffer.Write(escapeBuf)
} else {
buffer.WriteByte(byte(r))

View File

@ -5,6 +5,7 @@
package render
import (
"encoding/json"
"encoding/xml"
"errors"
"html/template"
@ -261,6 +262,48 @@ func TestRenderAsciiJSON(t *testing.T) {
assert.Equal(t, "3.1415926", w2.Body.String())
}
func TestRenderAsciiJSONNonBMP(t *testing.T) {
// Non-BMP code points (> U+FFFF) must be encoded as UTF-16 surrogate pairs.
// Previously, fmt.Appendf(buf, "\\u%04x", r) emitted 5+ hex digits for such
// runes, which is invalid JSON — decoders misread the first 4 digits as a
// different character and left the remaining digit(s) as literal text.
// RFC 8259 §7: \u escapes are exactly 4 hex digits; non-BMP values use pairs.
cases := []struct {
name string
input string
want string // exact \uHHHH\uLLLL literal
decoded string // value after json.Unmarshal round-trip
}{
// 😀 U+1F600: high=\uD83D low=\uDE00
{"grinning face", "😀", `\ud83d\ude00`, "😀"},
// 𝄞 U+1D11E: high=\uD834 low=\uDD1E
{"musical symbol G clef", "𝄞", `\ud834\udd1e`, "𝄞"},
// 𠀀 U+20000 (first CJK Extension B): high=\uD840 low=\uDC00
{"CJK Extension B first", "𠀀", `\ud840\udc00`, "𠀀"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
err := (AsciiJSON{map[string]string{"v": tc.input}}).Render(w)
require.NoError(t, err)
body := w.Body.String()
// The surrogate pair must appear verbatim in the raw output.
assert.Contains(t, body, tc.want,
"expected surrogate pair %q in raw output %q", tc.want, body)
// The round-trip via json.Unmarshal must recover the original rune.
var decoded map[string]string
require.NoError(t, json.Unmarshal([]byte(body), &decoded))
assert.Equal(t, tc.decoded, decoded["v"],
"round-trip mismatch: got %q want %q", decoded["v"], tc.decoded)
})
}
}
func TestRenderAsciiJSONFail(t *testing.T) {
w := httptest.NewRecorder()
data := make(chan int)