diff --git a/render/json.go b/render/json.go index 2f98676c..8fb13dee 100644 --- a/render/json.go +++ b/render/json.go @@ -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)) diff --git a/render/render_test.go b/render/render_test.go index f63878b9..68a06c9f 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -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)