From 5ec8793af6e95dc916dfe3f88d7d206da6e2d770 Mon Sep 17 00:00:00 2001 From: Daniel Mulzer Date: Thu, 27 Aug 2026 14:41:09 +0200 Subject: [PATCH] json: fix escapeIndex on big-endian architectures escapeIndex read 8-byte chunks via an unsafe native-endian cast, so bits.TrailingZeros64(...)/8 returned the mirrored byte index on big-endian (s390x). Characters needing escaping were skipped and written raw, producing invalid JSON. Use binary.LittleEndian.Uint64 like parse.go already does. Adds a regression test. --- json/string.go | 18 ++++----------- json/string_test.go | 56 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/json/string.go b/json/string.go index a9a972b6..62afe08d 100644 --- a/json/string.go +++ b/json/string.go @@ -1,8 +1,8 @@ package json import ( + "encoding/binary" "math/bits" - "unsafe" ) const ( @@ -16,8 +16,8 @@ const ( // the chars <, > and & also require escaping. If no chars in `s` require // escaping, the return value is -1. func escapeIndex(s string, escapeHTML bool) int { - chunks := stringToUint64(s) - for _, n := range chunks { + for i := 0; i+8 <= len(s); i += 8 { + n := binary.LittleEndian.Uint64([]byte(s[i : i+8])) // combine masks before checking for the MSB of each byte. We include // `n` in the mask to check whether any of the *input* byte MSBs were // set (i.e. the byte was outside the ASCII range). @@ -26,11 +26,11 @@ func escapeIndex(s string, escapeHTML bool) int { mask |= contains(n, '<') | contains(n, '>') | contains(n, '&') } if (mask & msb) != 0 { - return bits.TrailingZeros64(mask&msb) / 8 + return bits.TrailingZeros64(mask&msb)/8 + i } } - for i := len(chunks) * 8; i < len(s); i++ { + for i := (len(s) / 8) * 8; i < len(s); i++ { c := s[i] if c < 0x20 || c > 0x7f || c == '"' || c == '\\' || (escapeHTML && (c == '<' || c == '>' || c == '&')) { return i @@ -79,11 +79,3 @@ func contains(n uint64, b byte) uint64 { func expand(b byte) uint64 { return lsb * uint64(b) } - -func stringToUint64(s string) []uint64 { - return *(*[]uint64)(unsafe.Pointer(&sliceHeader{ - Data: *(*unsafe.Pointer)(unsafe.Pointer(&s)), - Len: len(s) / 8, - Cap: len(s) / 8, - })) -} diff --git a/json/string_test.go b/json/string_test.go index 8c2c614d..0cfaeb90 100644 --- a/json/string_test.go +++ b/json/string_test.go @@ -1,6 +1,7 @@ package json import ( + stdjson "encoding/json" "strings" "testing" ) @@ -36,3 +37,58 @@ func benchmarkEscapeIndex(b *testing.B, s string, escapeHTML bool) { } b.SetBytes(int64(len(s))) } + +// reference: index of the first byte that needs escaping, or -1 +func refEscapeIndex(s string, escapeHTML bool) int { + for i := 0; i < len(s); i++ { + c := s[i] + if c < 0x20 || c > 0x7f || c == '"' || c == '\\' || + (escapeHTML && (c == '<' || c == '>' || c == '&')) { + return i + } + } + return -1 +} + +func TestEscapeIndexBigEndian(t *testing.T) { + inputs := []string{ + `C:\Users\admin`, // backslash at byte 2 + "key\tvalue", // tab at byte 3 + "\nfoo_bar_", // newline at byte 0 + `ab"cdefg`, // quote at byte 2 + "line1\nline2", // newline at byte 5 + `say "hello"`, // quote at byte 4 + "1234567\nabc", // last byte of first chunk + "12345678\nabc", // first byte of second chunk + "-----BEGIN CERTIFICATE-----\nABCD\n-----END CERTIFICATE-----\n", + "&more", // HTML characters + "plain ascii with no escapes here", // no escaping needed + strings.Repeat("a", 33) + `"`, // quote far into the string + } + + for _, s := range inputs { + for _, html := range []bool{false, true} { + if got, want := escapeIndex(s, html), refEscapeIndex(s, html); got != want { + t.Errorf("escapeIndex(%q, %v) = %d, want %d", s, html, got, want) + } + } + + b, err := Marshal(struct { + V string `json:"v"` + }{s}) + if err != nil { + t.Errorf("Marshal(%q): %v", s, err) + continue + } + var out struct { + V string `json:"v"` + } + if err := stdjson.Unmarshal(b, &out); err != nil { + t.Errorf("%q produced invalid JSON %s: %v", s, b, err) + continue + } + if out.V != s { + t.Errorf("%q round-trips to %q", s, out.V) + } + } +}