From e3229dd57a8b14703afc047b7b0cb076fdf813ee Mon Sep 17 00:00:00 2001 From: KBS Date: Thu, 10 Sep 2026 12:12:30 +0900 Subject: [PATCH] Quote strings that begin or end with non-ASCII whitespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decoder strips quoteless strings with strings.TrimSpace, which is Unicode-aware, but needsQuotes anchors on Go RE2 \s, which is ASCII only. A value starting or ending with U+00A0, U+1680, U+2000-U+200A, U+205F or U+3000 was therefore written quoteless and came back short: Marshal(map[string]interface{}{"k": " ab"}) // { // k:  ab // } Unmarshal of that output gives "ab" needsQuotes already enumerates non-ASCII code points by hand for exactly this reason; the list just stops short of the spaces TrimSpace removes. Test with the same function the decoder uses, so the two cannot drift. hjson-js quotes all of these. --- encode.go | 3 +++ encode_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/encode.go b/encode.go index 3345a0b..82d2f14 100644 --- a/encode.go +++ b/encode.go @@ -134,6 +134,9 @@ func (e *hjsonEncoder) quote(value string, separator string, isRootObject bool, } else if e.QuoteAlways || hasCommentAfter || needsQuotes.MatchString(value) || + // The parser trims quoteless strings with strings.TrimSpace, which also + // removes non-ASCII whitespace that the needsQuotes regexp does not match. + len(value) != len(strings.TrimSpace(value)) || (e.QuoteAmbiguousStrings && (startsWithNumber([]byte(value)) || startsWithKeyword.MatchString(value))) { diff --git a/encode_test.go b/encode_test.go index 9e1480f..acb4ec8 100644 --- a/encode_test.go +++ b/encode_test.go @@ -875,3 +875,37 @@ func TestStructComment(t *testing.T) { t.Errorf("Expected:\n%s\nGot:\n%s\n\n", expected, string(h)) } } + +func TestQuoteUnicodeWhitespace(t *testing.T) { + // The parser trims quoteless strings with strings.TrimSpace, so the encoder + // must quote strings that start or end with a rune unicode.IsSpace reports. + tests := []struct { + r rune + wantQuoted bool + }{ + {'\u0085', true}, {'\u00a0', true}, {'\u1680', true}, {'\u2000', true}, + {'\u200a', true}, {'\u2028', true}, {'\u2029', true}, {'\u202f', true}, + {'\u205f', true}, {'\u3000', true}, + {'\u200b', false}, {'\u180e', false}, + } + for _, tt := range tests { + for _, value := range []string{string(tt.r) + "ab", "ab" + string(tt.r)} { + b, err := Marshal(map[string]interface{}{"k": value}) + if err != nil { + t.Errorf("%U: Marshal: %v", tt.r, err) + continue + } + if quoted := bytes.Contains(b, []byte(`k: "`)); quoted != tt.wantQuoted { + t.Errorf("%U: quoted %v, want %v (encoded as %q)", tt.r, quoted, tt.wantQuoted, b) + } + var dst map[string]interface{} + if err := Unmarshal(b, &dst); err != nil { + t.Errorf("%U: Unmarshal(%q): %v", tt.r, b, err) + continue + } + if got := dst["k"]; got != value { + t.Errorf("%U: got %q, want %q (encoded as %q)", tt.r, got, value, b) + } + } + } +}