Skip to content

Commit 2b665b1

Browse files
jmbobyclaude
andauthored
fix: preserve non-BMP unicode in templated config field rendering (SC-135815) (#5909)
Fields containing both repl{{}} template expressions and non-BMP unicode characters (codepoints > U+FFFF such as emoji) previously crashed the text/template parser with `unexpected "\\" in operand`. The root cause is that gopkg.in/yaml.v2 considers non-BMP codepoints non-printable, so it force-emits them as \UXXXXXXXX escapes inside double-quoted strings, and double-quoting in turn escapes any " in the value as \" - which breaks template parsing when \" lands inside a {{ ... }} delimiter. Fix MarshalConfig with two passes: 1. normalizeTemplateStyles: re-parse the yaml.v2 output with yaml.v3, walk scalar nodes whose value contains repl{{ or {{repl, swap any non-BMP rune for an ASCII placeholder so the encoder picks single- quoted or literal-block style instead of double-quoted, re-emit, then restore the original UTF-8 runes. 2. decodeUnicodeEscapes: post-process the final string to decode any remaining \UXXXXXXXX (non-BMP), \uXXXX\uXXXX (UTF-16 surrogate pair), or \uXXXX (BMP) escapes back to their UTF-8 equivalents. Tests cover: - The exact ticket repro: multi-line |- block scalar + repl{{}} + non-BMP emoji - asserts the new marshaller succeeds AND the old marshaller still fails (regression guard). - The customer-supplied workaround pattern (printf "\UXXXXXXXX" inside a repl template, ASCII-only source) - asserts both old and new marshallers produce the same correct rendered output, so the fix does not disturb the workaround. - decodeUnicodeEscapes unit tests covering 8-digit escapes, UTF-16 surrogate pairs, BMP escapes, lone surrogates, and invalid codepoints. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b9e9105 commit 2b665b1

2 files changed

Lines changed: 345 additions & 1 deletion

File tree

pkg/config/config.go

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ package config
33
import (
44
"bytes"
55
"fmt"
6+
"regexp"
7+
"strconv"
68
"strings"
9+
"unicode"
10+
"unicode/utf16"
11+
"unicode/utf8"
712

813
"github.com/google/uuid"
914
"github.com/pkg/errors"
@@ -13,10 +18,21 @@ import (
1318
kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
1419
"github.com/replicatedhq/kotskinds/multitype"
1520
"github.com/replicatedhq/kotskinds/pkg/licensewrapper"
21+
goyaml "go.yaml.in/yaml/v3"
1622
"k8s.io/apimachinery/pkg/runtime/serializer/json"
1723
"k8s.io/client-go/kubernetes/scheme"
1824
)
1925

26+
// Regexps for decoding Unicode escape sequences introduced by yaml.v2 marshalling.
27+
// yaml.v2 escapes non-BMP characters (codepoints > U+FFFF) as \UXXXXXXXX because its
28+
// isPrintable() excludes the 0x10000-0x10FFFF range. These escape sequences break
29+
// Go's text/template parser when they appear in config fields alongside repl{{}} expressions.
30+
var (
31+
reUnicodeEscape8 = regexp.MustCompile(`\\U([0-9A-Fa-f]{8})`)
32+
reSurrogatePair = regexp.MustCompile(`\\u([Dd][89ABab][0-9A-Fa-f]{2})\\u([Dd][C-Fc-f][0-9A-Fa-f]{2})`)
33+
reUnicodeEscape4 = regexp.MustCompile(`\\u([0-9A-Fa-f]{4})`)
34+
)
35+
2036
func TemplateConfigObjects(configSpec *kotsv1beta1.Config, configValues map[string]template.ItemValue, license *licensewrapper.LicenseWrapper, app *kotsv1beta1.Application, localRegistry registrytypes.RegistrySettings, versionInfo *template.VersionInfo, appInfo *template.ApplicationInfo, identityconfig *kotsv1beta1.IdentityConfig, namespace string, decryptValues bool) (*kotsv1beta1.Config, error) {
2137
templatedString, err := templateConfigObjects(configSpec, configValues, license, app, localRegistry, versionInfo, appInfo, identityconfig, namespace, decryptValues, MarshalConfig)
2238
if err != nil {
@@ -156,7 +172,123 @@ func MarshalConfig(config *kotsv1beta1.Config) (string, error) {
156172
return "", errors.Wrap(err, "failed to fix up yaml")
157173
}
158174

159-
return string(b), nil
175+
normalized, err := normalizeTemplateStyles(b)
176+
if err != nil {
177+
return "", errors.Wrap(err, "failed to normalize template styles")
178+
}
179+
180+
return decodeUnicodeEscapes(string(normalized)), nil
181+
}
182+
183+
// decodeUnicodeEscapes replaces YAML/JSON Unicode escape sequences with their UTF-8 equivalents.
184+
// This is needed because the K8s serialiser routes through gopkg.in/yaml.v2, which escapes
185+
// non-BMP Unicode characters (codepoints > U+FFFF) as \UXXXXXXXX in double-quoted strings.
186+
// Processing order matters: \UXXXXXXXX first, then surrogate pairs, then standalone \uXXXX.
187+
func decodeUnicodeEscapes(s string) string {
188+
// Decode \UXXXXXXXX (8-digit YAML escapes for non-BMP codepoints)
189+
s = reUnicodeEscape8.ReplaceAllStringFunc(s, func(match string) string {
190+
codepoint, err := strconv.ParseUint(match[2:], 16, 32)
191+
if err != nil || !utf8.ValidRune(rune(codepoint)) {
192+
return match
193+
}
194+
return string(rune(codepoint))
195+
})
196+
197+
// Decode \uXXXX\uXXXX (JSON-style UTF-16 surrogate pairs)
198+
s = reSurrogatePair.ReplaceAllStringFunc(s, func(match string) string {
199+
high, err1 := strconv.ParseUint(match[2:6], 16, 16)
200+
low, err2 := strconv.ParseUint(match[8:12], 16, 16)
201+
if err1 != nil || err2 != nil {
202+
return match
203+
}
204+
r := utf16.DecodeRune(rune(high), rune(low))
205+
if r == unicode.ReplacementChar {
206+
return match
207+
}
208+
return string(r)
209+
})
210+
211+
// Decode standalone \uXXXX (BMP codepoints, skip surrogates)
212+
s = reUnicodeEscape4.ReplaceAllStringFunc(s, func(match string) string {
213+
codepoint, err := strconv.ParseUint(match[2:], 16, 16)
214+
if err != nil || (codepoint >= 0xD800 && codepoint <= 0xDFFF) {
215+
return match
216+
}
217+
if !utf8.ValidRune(rune(codepoint)) {
218+
return match
219+
}
220+
return string(rune(codepoint))
221+
})
222+
223+
return s
224+
}
225+
226+
// reNonBMP matches any Unicode character above U+FFFF (non-BMP codepoints).
227+
var reNonBMP = regexp.MustCompile(`[\x{10000}-\x{10FFFF}]`)
228+
229+
// normalizeTemplateStyles ensures YAML strings containing template expressions
230+
// (repl{{ or {{repl) are not double-quoted. The go.yaml.in/yaml/v3 library considers
231+
// non-BMP Unicode characters (> U+FFFF) non-printable, forcing double-quoted style
232+
// with \UXXXXXXXX escapes. This also introduces \" for any " in the value, which
233+
// breaks Go's text/template parser when \" appears inside template delimiters.
234+
//
235+
// The approach: temporarily replace non-BMP characters with ASCII placeholders so
236+
// the YAML encoder chooses single-quoted (or literal block) style, then restore
237+
// the original UTF-8 characters in the encoded output.
238+
func normalizeTemplateStyles(yamlBytes []byte) ([]byte, error) {
239+
var doc goyaml.Node
240+
if err := goyaml.Unmarshal(yamlBytes, &doc); err != nil {
241+
return nil, err
242+
}
243+
244+
// Track placeholder→original mappings
245+
placeholders := map[string]string{}
246+
walkYAMLNodes(&doc, func(node *goyaml.Node) {
247+
if node.Kind != goyaml.ScalarNode {
248+
return
249+
}
250+
if !strings.Contains(node.Value, "repl{{") && !strings.Contains(node.Value, "{{repl") {
251+
return
252+
}
253+
// Replace non-BMP characters with ASCII placeholders
254+
node.Value = reNonBMP.ReplaceAllStringFunc(node.Value, func(ch string) string {
255+
r := []rune(ch)[0]
256+
placeholder := fmt.Sprintf("__KOTS_U%08X__", r)
257+
placeholders[placeholder] = ch
258+
return placeholder
259+
})
260+
// Now the value is BMP-only, so the encoder can use non-double-quoted styles
261+
if strings.Contains(node.Value, "\n") {
262+
node.Style = goyaml.LiteralStyle
263+
} else {
264+
node.Style = goyaml.SingleQuotedStyle
265+
}
266+
})
267+
268+
var buf bytes.Buffer
269+
enc := goyaml.NewEncoder(&buf)
270+
enc.SetIndent(2)
271+
if err := enc.Encode(&doc); err != nil {
272+
return nil, err
273+
}
274+
275+
// Restore original UTF-8 characters
276+
result := buf.String()
277+
for placeholder, original := range placeholders {
278+
result = strings.ReplaceAll(result, placeholder, original)
279+
}
280+
281+
return []byte(result), nil
282+
}
283+
284+
func walkYAMLNodes(node *goyaml.Node, fn func(*goyaml.Node)) {
285+
if node == nil {
286+
return
287+
}
288+
fn(node)
289+
for _, child := range node.Content {
290+
walkYAMLNodes(child, fn)
291+
}
160292
}
161293

162294
func UnmarshalConfigValuesContent(content []byte) (map[string]template.ItemValue, error) {

pkg/config/config_test.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,163 @@ spec:
341341
secretName-1: "123"
342342
secretName-2: "456"
343343
secretName-3: "789"
344+
`,
345+
expectOldFail: false,
346+
},
347+
{
348+
name: "non-BMP unicode in help_text with template function",
349+
configSpecData: `
350+
apiVersion: kots.io/v1beta1
351+
kind: Config
352+
metadata:
353+
name: test-app
354+
spec:
355+
groups:
356+
- name: example_settings
357+
title: My Example Config
358+
items:
359+
- name: other_field
360+
title: Other Field
361+
type: text
362+
default: "hello"
363+
- name: a_field
364+
title: A Field
365+
type: bool
366+
default: "1"
367+
help_text: "🔏 This field is locked repl{{ ConfigOption \"other_field\" }}"`,
368+
configValuesData: map[string]template.ItemValue{
369+
"other_field": {
370+
Value: "world",
371+
},
372+
},
373+
useAppSpec: false,
374+
want: `apiVersion: kots.io/v1beta1
375+
kind: Config
376+
metadata:
377+
name: test-app
378+
spec:
379+
groups:
380+
- items:
381+
- default: "hello"
382+
name: other_field
383+
title: Other Field
384+
type: text
385+
value: world
386+
- default: "1"
387+
help_text: "🔏 This field is locked world"
388+
name: a_field
389+
title: A Field
390+
type: bool
391+
value: ""
392+
name: example_settings
393+
title: My Example Config
394+
status: {}
395+
`,
396+
expectOldFail: true,
397+
},
398+
{
399+
name: "non-BMP unicode in multi-line block-scalar help_text with repl template (SC-135815)",
400+
configSpecData: `
401+
apiVersion: kots.io/v1beta1
402+
kind: Config
403+
metadata:
404+
name: test-app
405+
spec:
406+
groups:
407+
- name: test_group
408+
title: Test
409+
items:
410+
- name: other_field
411+
title: Other Field
412+
type: bool
413+
default: "1"
414+
- name: test_field
415+
title: Test Field
416+
type: bool
417+
default: "1"
418+
help_text: |-
419+
Some description text
420+
421+
🔏 **This field may not be edited once set**
422+
423+
repl{{ if (ne Sequence 0) }}
424+
repl{{- if ConfigOptionEquals "other_field" "1" }}
425+
ℹ️ A conditional note
426+
repl{{- end }}
427+
repl{{- end }}`,
428+
configValuesData: map[string]template.ItemValue{
429+
"other_field": {
430+
Value: "1",
431+
},
432+
},
433+
useAppSpec: false,
434+
want: `apiVersion: kots.io/v1beta1
435+
kind: Config
436+
metadata:
437+
name: test-app
438+
spec:
439+
groups:
440+
- items:
441+
- default: "1"
442+
name: other_field
443+
title: Other Field
444+
type: bool
445+
value: "1"
446+
- default: "1"
447+
help_text: |-
448+
Some description text
449+
450+
🔏 **This field may not be edited once set**
451+
name: test_field
452+
title: Test Field
453+
type: bool
454+
value: ""
455+
name: test_group
456+
title: Test
457+
status: {}
458+
`,
459+
expectOldFail: true,
460+
},
461+
{
462+
name: "customer workaround: printf escape in repl template renders to non-BMP emoji",
463+
configSpecData: `
464+
apiVersion: kots.io/v1beta1
465+
kind: Config
466+
metadata:
467+
name: sample-app
468+
spec:
469+
groups:
470+
- name: sample-group
471+
title: Sample Title
472+
description: |
473+
sample description
474+
items:
475+
- name: dns_support_choice
476+
title: DNS Support
477+
type: text
478+
required: true
479+
help_text: |
480+
repl{{printf "\U0001F512"}}`,
481+
configValuesData: map[string]template.ItemValue{},
482+
useAppSpec: false,
483+
want: `apiVersion: kots.io/v1beta1
484+
kind: Config
485+
metadata:
486+
name: sample-app
487+
spec:
488+
groups:
489+
- description: |
490+
sample description
491+
items:
492+
- help_text: "🔒"
493+
name: dns_support_choice
494+
required: true
495+
title: DNS Support
496+
type: text
497+
value: ""
498+
name: sample-group
499+
title: Sample Title
500+
status: {}
344501
`,
345502
expectOldFail: false,
346503
},
@@ -493,3 +650,58 @@ func TestApplyValuesToConfig(t *testing.T) {
493650
})
494651
}
495652
}
653+
654+
func TestDecodeUnicodeEscapes(t *testing.T) {
655+
tests := []struct {
656+
name string
657+
input string
658+
want string
659+
}{
660+
{
661+
name: "8-digit non-BMP escape",
662+
input: `\U0001F510`,
663+
want: "\U0001F510",
664+
},
665+
{
666+
name: "surrogate pair",
667+
input: `\uD83D\uDD10`,
668+
want: "\U0001F510",
669+
},
670+
{
671+
name: "4-digit BMP escape",
672+
input: `\u00E9`,
673+
want: "\u00E9",
674+
},
675+
{
676+
name: "no escapes unchanged",
677+
input: "hello world",
678+
want: "hello world",
679+
},
680+
{
681+
name: "invalid codepoint left unchanged",
682+
input: `\UFFFFFFFF`,
683+
want: `\UFFFFFFFF`,
684+
},
685+
{
686+
name: "lone surrogate left unchanged",
687+
input: `\uD800`,
688+
want: `\uD800`,
689+
},
690+
{
691+
name: "mixed content preserved",
692+
input: "text before \\U0001F510 text after",
693+
want: "text before \U0001F510 text after",
694+
},
695+
{
696+
name: "multiple escapes decoded",
697+
input: `\U0001F510 and \U0001F512`,
698+
want: "\U0001F510 and \U0001F512",
699+
},
700+
}
701+
for _, tt := range tests {
702+
t.Run(tt.name, func(t *testing.T) {
703+
got := decodeUnicodeEscapes(tt.input)
704+
require.Equal(t, tt.want, got)
705+
})
706+
}
707+
}

0 commit comments

Comments
 (0)