From 9dd3f87e02e188c302e70029bad7a111f461f39b Mon Sep 17 00:00:00 2001 From: kkh Date: Wed, 7 Jan 2026 22:49:02 +0900 Subject: [PATCH 1/2] fix: escape non-ASCII OCI annotation values Signed-off-by: kkh --- pkg/registry/chart.go | 43 +++++++++++++++++++++++++-- pkg/registry/chart_test.go | 60 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) 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() From 4f8b820f0f698d86e7765dd00ccf0588deb84668 Mon Sep 17 00:00:00 2001 From: kkh Date: Thu, 8 Jan 2026 14:52:14 +0900 Subject: [PATCH 2/2] fix(registry): hint on non-ASCII annotation mismatch Signed-off-by: kkh --- pkg/registry/chart.go | 43 ++------------------------ pkg/registry/chart_test.go | 60 ------------------------------------- pkg/registry/client.go | 23 ++++++++++++++ pkg/registry/client_test.go | 51 +++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 101 deletions(-) diff --git a/pkg/registry/chart.go b/pkg/registry/chart.go index 0a49d30e5..b00fc616d 100644 --- a/pkg/registry/chart.go +++ b/pkg/registry/chart.go @@ -18,11 +18,8 @@ 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" @@ -62,7 +59,7 @@ annotations: } // Add chart annotation - ociAnnotations[chartAnnotationKey] = escapeNonASCII(chartAnnotationValue) + ociAnnotations[chartAnnotationKey] = chartAnnotationValue } return ociAnnotations @@ -120,44 +117,8 @@ 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] = escapeNonASCII(newValue) + inputMap[newKey] = 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 f268e6e2b..77ccdaab7 100644 --- a/pkg/registry/chart_test.go +++ b/pkg/registry/chart_test.go @@ -63,20 +63,6 @@ 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{ @@ -212,22 +198,6 @@ 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{ @@ -261,36 +231,6 @@ 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() diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 750bb9715..c546cc590 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -30,11 +30,13 @@ import ( "os" "sort" "strings" + "unicode/utf8" "github.com/Masterminds/semver/v3" "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" @@ -715,6 +717,9 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu manifestDescriptor, err = oras.ExtendedCopy(ctx, memoryStore, parsedRef.String(), repository, parsedRef.String(), oras.DefaultExtendedCopyOptions) if err != nil { + if hasNonASCIIAnnotationValues(ociAnnotations) && errors.Is(err, content.ErrMismatchedDigest) { + return nil, fmt.Errorf("manifest digest mismatch while pushing; the registry may be rewriting non-ASCII OCI annotation values. Consider using ASCII-only metadata/annotations or a registry that preserves annotations: %w", err) + } return nil, err } @@ -752,6 +757,24 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu return result, err } +func hasNonASCIIAnnotationValues(annotations map[string]string) bool { + for key, value := range annotations { + if containsNonASCII(key) || containsNonASCII(value) { + return true + } + } + return false +} + +func containsNonASCII(value string) bool { + for i := 0; i < len(value); i++ { + if value[i] >= utf8.RuneSelf { + return true + } + } + return false +} + // PushOptProvData returns a function that sets the prov bytes setting on push func PushOptProvData(provData []byte) PushOption { return func(operation *pushOperation) { diff --git a/pkg/registry/client_test.go b/pkg/registry/client_test.go index 98a8b2ea3..e80c2db74 100644 --- a/pkg/registry/client_test.go +++ b/pkg/registry/client_test.go @@ -166,3 +166,54 @@ func TestWarnIfHostHasPath(t *testing.T) { }) } } + +func TestHasNonASCIIAnnotationValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + annotations map[string]string + want bool + }{ + { + name: "ascii only", + annotations: map[string]string{ + "org.opencontainers.image.title": "chart", + "custom": "alpha-._:@/+ 123", + }, + want: false, + }, + { + name: "non-ascii value", + annotations: map[string]string{ + "org.opencontainers.image.description": "Kröpke", + }, + want: true, + }, + { + name: "non-ascii key", + annotations: map[string]string{ + "\uC124\uBA85": "chart", + }, + want: true, + }, + { + name: "emoji value", + annotations: map[string]string{ + "note": "chart \U0001F600", + }, + want: true, + }, + { + name: "empty map", + annotations: map[string]string{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, hasNonASCIIAnnotationValues(tt.annotations)) + }) + } +}