diff --git a/pkg/registry/chart.go b/pkg/registry/chart.go index b00fc616d..0a49d30e5 100644 --- a/pkg/registry/chart.go +++ b/pkg/registry/chart.go @@ -18,8 +18,11 @@ package registry // import "helm.sh/helm/v4/pkg/registry" import ( "bytes" + "strconv" "strings" "time" + "unicode/utf16" + "unicode/utf8" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" @@ -59,7 +62,7 @@ annotations: } // Add chart annotation - ociAnnotations[chartAnnotationKey] = chartAnnotationValue + ociAnnotations[chartAnnotationKey] = escapeNonASCII(chartAnnotationValue) } return ociAnnotations @@ -117,8 +120,44 @@ func addToMap(inputMap map[string]string, newKey string, newValue string) map[st // Add item to map if its if len(strings.TrimSpace(newValue)) > 0 { - inputMap[newKey] = newValue + inputMap[newKey] = escapeNonASCII(newValue) } return inputMap } + +func escapeNonASCII(value string) string { + if value == "" { + return value + } + + var escaped strings.Builder + escaped.Grow(len(value)) + + for _, r := range value { + if r < utf8.RuneSelf { + escaped.WriteRune(r) + continue + } + + if r <= 0xFFFF { + writeEscapedRune(&escaped, r) + continue + } + + high, low := utf16.EncodeRune(r) + writeEscapedRune(&escaped, high) + writeEscapedRune(&escaped, low) + } + + return escaped.String() +} + +func writeEscapedRune(builder *strings.Builder, r rune) { + builder.WriteString("\\u") + hex := strconv.FormatInt(int64(r), 16) + for i := len(hex); i < 4; i++ { + builder.WriteByte('0') + } + builder.WriteString(hex) +} diff --git a/pkg/registry/chart_test.go b/pkg/registry/chart_test.go index 77ccdaab7..f268e6e2b 100644 --- a/pkg/registry/chart_test.go +++ b/pkg/registry/chart_test.go @@ -63,6 +63,20 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { "org.opencontainers.image.url": "https://helm.sh", }, }, + { + "Chart values with non-ASCII", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart for Kr\u00f6pke", + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.created": nowString, + "org.opencontainers.image.description": "OCI Helm Chart for Kr\\u00f6pke", + }, + }, { "Maintainer without email", &chart.Metadata{ @@ -198,6 +212,22 @@ func TestGenerateOCIAnnotations(t *testing.T) { "anotherkey": "anothervalue", }, }, + { + "Custom annotations with non-ASCII values", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Annotations: map[string]string{ + "extrakey": "Kr\u00f6pke", + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.created": nowString, + "extrakey": "Kr\\u00f6pke", + }, + }, { "Verify Chart Name and Version cannot be overridden from annotations", &chart.Metadata{ @@ -231,6 +261,36 @@ func TestGenerateOCIAnnotations(t *testing.T) { } } +func TestEscapeNonASCII(t *testing.T) { + tests := []struct { + name string + input string + expect string + }{ + { + name: "ASCII only", + input: "alpha-._:@/+ 123", + expect: "alpha-._:@/+ 123", + }, + { + name: "Latin-1 characters", + input: "Kr\u00f6pke", + expect: "Kr\\u00f6pke", + }, + { + name: "Emoji", + input: "chart \U0001f600", + expect: "chart \\ud83d\\ude00", + }, + } + + for _, tt := range tests { + if got := escapeNonASCII(tt.input); got != tt.expect { + t.Errorf("%s: expected %q, got %q", tt.name, tt.expect, got) + } + } +} + func TestGenerateOCICreatedAnnotations(t *testing.T) { nowTime := time.Now()