diff --git a/.github/env b/.github/env index 9a0c1ef4c..eb1fa0ee8 100644 --- a/.github/env +++ b/.github/env @@ -1,2 +1,2 @@ -GOLANG_VERSION=1.26 -GOLANGCI_LINT_VERSION=v2.12.2 +GOLANG_VERSION=1.27 +GOLANGCI_LINT_VERSION=v2.13.1 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6bfec7108..d4d9f5228 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -48,7 +48,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # pinv4.37.7 + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # pinv4.37.8 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -59,7 +59,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # pinv4.37.7 + uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # pinv4.37.8 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -73,4 +73,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # pinv4.37.7 + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # pinv4.37.8 diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 0a12abf49..6d5367952 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: sarif_file: results.sarif diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go index 60df8a308..42442d392 100644 --- a/cmd/helm/helm.go +++ b/cmd/helm/helm.go @@ -42,8 +42,7 @@ func main() { } if err := cmd.Execute(); err != nil { - var cerr helmcmd.CommandError - if errors.As(err, &cerr) { + if cerr, ok := errors.AsType[helmcmd.CommandError](err); ok { os.Exit(cerr.ExitCode) } os.Exit(1) diff --git a/go.mod b/go.mod index 6a691efac..bf8bb7228 100644 --- a/go.mod +++ b/go.mod @@ -32,21 +32,21 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/stretchr/testify v1.12.0 + github.com/stretchr/testify v1.12.1 github.com/tetratelabs/wazero v1.12.0 go.yaml.in/yaml/v3 v3.0.5 golang.org/x/crypto v0.55.0 golang.org/x/term v0.45.0 golang.org/x/text v0.41.0 gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.36.3 - k8s.io/apiextensions-apiserver v0.36.3 - k8s.io/apimachinery v0.36.3 - k8s.io/apiserver v0.36.3 - k8s.io/cli-runtime v0.36.3 - k8s.io/client-go v0.36.3 + k8s.io/api v0.36.4 + k8s.io/apiextensions-apiserver v0.36.4 + k8s.io/apimachinery v0.36.4 + k8s.io/apiserver v0.36.4 + k8s.io/cli-runtime v0.36.4 + k8s.io/client-go v0.36.4 k8s.io/klog/v2 v2.140.0 - k8s.io/kubectl v0.36.3 + k8s.io/kubectl v0.36.4 oras.land/oras-go/v2 v2.6.2 sigs.k8s.io/controller-runtime v0.24.1 sigs.k8s.io/kustomize/kyaml v0.21.1 @@ -172,7 +172,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/component-base v0.36.3 // indirect + k8s.io/component-base v0.36.4 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/go.sum b/go.sum index 54d14492a..b9d8e51fc 100644 --- a/go.sum +++ b/go.sum @@ -307,8 +307,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= @@ -490,26 +490,26 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= -k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= -k8s.io/apiextensions-apiserver v0.36.3 h1:dPmOAPhwTtqb1bTxbFPsy18KHPhktQeO3WUPXunZIB0= -k8s.io/apiextensions-apiserver v0.36.3/go.mod h1:KTXFqgXiuw2pRoL+Wpmttqc+up9Xt/GohadPWeLLOa4= -k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= -k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= -k8s.io/apiserver v0.36.3 h1:MGSg2SkdfuytiDEcRylT5mQFmmSsbx90XFUO67Y4bsQ= -k8s.io/apiserver v0.36.3/go.mod h1:fVH7zv9EUNUA7Fl7LtDKh8aB9W7u1VQPSGtWV5SjUxg= -k8s.io/cli-runtime v0.36.3 h1:g+eJ+M1sYpnNYp/q5fzaw2KejIL0Q7DH+xFl6YVoL4U= -k8s.io/cli-runtime v0.36.3/go.mod h1:hZpAqK8nSFXvvLaVCbzUPVp8e9TRLSTCfpNzMt7s3tE= -k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= -k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= -k8s.io/component-base v0.36.3 h1:vc/UFvPCkW0irPz84LAodAL1j3f4xktPM6dDJIEheAY= -k8s.io/component-base v0.36.3/go.mod h1:hZbNFG+gCMl9EbykDGEu73feKP9/Cq6JsV4pTo9GTO8= +k8s.io/api v0.36.4 h1:RxrvqCL6vgH5/+UnTeu1IIFqYmGfy0hnyrod1rn35Oo= +k8s.io/api v0.36.4/go.mod h1:S2B3orCFBDhrgyWbLeuKcT2QdHIpQesBkCYSlWtwUOw= +k8s.io/apiextensions-apiserver v0.36.4 h1:SfvCVt+4CqKWvzuVytYDT5g9hyb9MztoiYELIkPVrFc= +k8s.io/apiextensions-apiserver v0.36.4/go.mod h1:JT9V2Ju7ys1FY4zbSpmX9XOvKB3/BwsODc4hFQEa+Xo= +k8s.io/apimachinery v0.36.4 h1:PT2UzkupGuAx/+xT5XjiMJ1WGpY3fn9/hdAvjweRet4= +k8s.io/apimachinery v0.36.4/go.mod h1:p2I2dipt7JHG+quVwQ1d02d28O4GdDi77RByQ13MTpk= +k8s.io/apiserver v0.36.4 h1:AtKjaf2eUiX5G6TfF2IOlhuUuvMsHh49Ivr1+4fZ2gA= +k8s.io/apiserver v0.36.4/go.mod h1:RyiGghXP67hb0Ll+7iLJ6GGv2JpEzCn7ljbiA+L3cJ0= +k8s.io/cli-runtime v0.36.4 h1:OHvManCwP1k9GiC5tXRFxHhzZIQQFCsrHlt7OspKo3w= +k8s.io/cli-runtime v0.36.4/go.mod h1:qQSj2FJgQos6GHpS/ge7wTdQMZm9XFWlesWgV6h7qZY= +k8s.io/client-go v0.36.4 h1:MDvfDNvMSt0Br94SK8neviVlwL9qifw9B26hJCpD1K0= +k8s.io/client-go v0.36.4/go.mod h1:pNK4WKELbwlEDvtbE8l22lEZL5THYF61H5EealokZmA= +k8s.io/component-base v0.36.4 h1:tz75yC2xgq3kd7vPdBtR8do5iMx0OHf6Zd1kuaxDB84= +k8s.io/component-base v0.36.4/go.mod h1:DCwb306U8ou89NNAp45Csuy8ok+1rp1ELDVPhzN5AWc= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/kubectl v0.36.3 h1:TesKp+XYQEjPYoFvuobcVnuvira2+/xAVlq//+kksaI= -k8s.io/kubectl v0.36.3/go.mod h1:W+NEb1CzBGmoaI1Nrpn2ETo9omNBl0AsyxnnMT40N6E= +k8s.io/kubectl v0.36.4 h1:xZd9g1bFBd7hpb1oKjK8lT9jRL18dtgr4DAPQG1Oksk= +k8s.io/kubectl v0.36.4/go.mod h1:STWlr78cdEa1hHpr55wpcboaqchvfDueKRNDa1zOd1w= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= diff --git a/internal/chart/v3/loader/load.go b/internal/chart/v3/loader/load.go index 0ff040eae..72343d7b6 100644 --- a/internal/chart/v3/loader/load.go +++ b/internal/chart/v3/loader/load.go @@ -123,7 +123,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { } fname := strings.TrimPrefix(f.Name, "charts/") - cname := strings.SplitN(fname, "/", 2)[0] + cname, _, _ := strings.Cut(fname, "/") if slices.Index(subChartsKeys, cname) == -1 { subChartsKeys = append(subChartsKeys, cname) } diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go index e1b3e0b97..8b84ef428 100644 --- a/internal/chart/v3/util/dependencies.go +++ b/internal/chart/v3/util/dependencies.go @@ -46,7 +46,6 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, if c != "" { // retrieve value vv, err := cvals.PathValue(cpath + c) - var errNoValue common.ErrNoValue if err == nil { // if not bool, warn if bv, ok := vv.(bool); ok { @@ -54,7 +53,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, break } slog.Warn("returned non-bool value", "path", c, "chart", r.Name) - } else if errors.As(err, &errNoValue) { + } else if _, ok := errors.AsType[common.ErrNoValue](err); ok { // this is a real error slog.Warn("the method PathValue returned error", slog.Any("error", err)) } diff --git a/internal/chart/v3/util/doc.go b/internal/chart/v3/util/doc.go index dc5a07462..70dc7199f 100644 --- a/internal/chart/v3/util/doc.go +++ b/internal/chart/v3/util/doc.go @@ -42,4 +42,4 @@ into a Chart. When creating charts in memory, use the 'helm.sh/helm/pkg/chart' package directly. */ -package util // import "helm.sh/helm/v4/internal/chart/v3/util" +package util diff --git a/internal/chart/v3/util/save_test.go b/internal/chart/v3/util/save_test.go index 26bb54019..00f2c5cf4 100644 --- a/internal/chart/v3/util/save_test.go +++ b/internal/chart/v3/util/save_test.go @@ -231,13 +231,10 @@ func TestSaveDir(t *testing.T) { } func TestRepeatableSave(t *testing.T) { - tmp := t.TempDir() - defer os.RemoveAll(tmp) modTime := time.Date(2021, 9, 1, 20, 34, 58, 651387237, time.UTC) tests := []struct { name string chart *chart.Chart - want string }{ { name: "Package 1 file", @@ -258,7 +255,6 @@ func TestRepeatableSave(t *testing.T) { Schema: []byte("{\n \"title\": \"Values\"\n}"), SchemaModTime: modTime, }, - want: "5bfea18cc3c8cbc265744bc32bffa9489a4dbe87d6b51b90f4255e4839d35e03", }, { name: "Package 2 files", @@ -280,20 +276,26 @@ func TestRepeatableSave(t *testing.T) { Schema: []byte("{\n \"title\": \"Values\"\n}"), SchemaModTime: modTime, }, - want: "a240365c21e0a2f4a57873132a9b686566a612d08bcb3f20c9446bfff005ccce", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - // create package - dest := path.Join(tmp, "newdir") - where, err := Save(test.chart, dest) + // Package the same chart twice, into separate directories so the + // second Save does not simply overwrite the first. + first, err := Save(test.chart, t.TempDir()) + require.NoError(t, err, "Failed to save") + second, err := Save(test.chart, t.TempDir()) require.NoError(t, err, "Failed to save") - // get shasum for package - result, err := sha256Sum(where) + + firstSum, err := sha256Sum(first) require.NoError(t, err, "Failed to check shasum") - // assert that the package SHA is what we wanted. - assert.Equal(t, test.want, result, "FormatName() result = %v, want %v", result, test.want) + secondSum, err := sha256Sum(second) + require.NoError(t, err, "Failed to check shasum") + + // Packaging a chart must be reproducible. The digest itself is not + // pinned here because the bytes depend on the compression + // implementation of the Go release Helm is built with. + assert.Equal(t, firstSum, secondSum, "Save() is not repeatable") }) } } diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go index ec81ffed6..ca7b26773 100644 --- a/internal/plugin/config_test.go +++ b/internal/plugin/config_test.go @@ -41,7 +41,7 @@ func TestUnmarshaConfig(t *testing.T) { ShortHelp: "short help string", LongHelp: "long help string", IgnoreFlags: true, - }, *(config.(*schema.ConfigCLIV1))) + }, *config.(*schema.ConfigCLIV1)) } // Test unmarshalling invalid config data diff --git a/internal/plugin/plugin_type_registry.go b/internal/plugin/plugin_type_registry.go index 5138422bd..78bd38e0f 100644 --- a/internal/plugin/plugin_type_registry.go +++ b/internal/plugin/plugin_type_registry.go @@ -16,7 +16,7 @@ limitations under the License. /* This file contains a "registry" of supported plugin types. -It enables "dyanmic" operations on the go type associated with a given plugin type (see: `helm.sh/helm/v4/internal/plugin/schema` package) +It enables "dynamic" operations on the go type associated with a given plugin type (see: `helm.sh/helm/v4/internal/plugin/schema` package) Examples: diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index 2dbe220d9..9ea69e116 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -74,7 +74,7 @@ func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) ( return &SubprocessPluginRuntime{ metadata: *metadata, pluginDir: pluginDir, - RuntimeConfig: *(metadata.RuntimeConfig.(*RuntimeConfigSubprocess)), + RuntimeConfig: *metadata.RuntimeConfig.(*RuntimeConfigSubprocess), EnvVars: maps.Clone(r.EnvVars), }, nil } @@ -153,8 +153,7 @@ func (r *SubprocessPluginRuntime) InvokeHook(event string) error { slog.Debug("executing plugin hook command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String())) if err := cmd.Run(); err != nil { - var eerr *exec.ExitError - if errors.As(err, &eerr) { + if eerr, ok := errors.AsType[*exec.ExitError](err); ok { os.Stderr.Write(eerr.Stderr) return fmt.Errorf("plugin %s hook for %q exited with error", event, r.metadata.Name) } @@ -168,8 +167,7 @@ func (r *SubprocessPluginRuntime) InvokeHook(event string) error { // then replace the other three with a call to this func func executeCmd(prog *exec.Cmd, pluginName string) error { if err := prog.Run(); err != nil { - var eerr *exec.ExitError - if errors.As(err, &eerr) { + if eerr, ok := errors.AsType[*exec.ExitError](err); ok { slog.Debug( "plugin execution failed", slog.String("pluginName", pluginName), diff --git a/internal/plugin/runtime_subprocess_getter.go b/internal/plugin/runtime_subprocess_getter.go index 3f07fa764..67feab94f 100644 --- a/internal/plugin/runtime_subprocess_getter.go +++ b/internal/plugin/runtime_subprocess_getter.go @@ -42,7 +42,7 @@ func getProtocolCommand(commands []SubprocessProtocolCommand, protocol string) * // TODO can we replace a lot of this func with RuntimeSubprocess.invokeWithEnv? func (r *SubprocessPluginRuntime) runGetter(ctx context.Context, input *Input) (*Output, error) { - msg, ok := (input.Message).(schema.InputMessageGetterV1) + msg, ok := input.Message.(schema.InputMessageGetterV1) if !ok { return nil, fmt.Errorf("expected input type schema.InputMessageGetterV1, got %T", input) } diff --git a/internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go b/internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go index ae62d0e6f..666e1be40 100644 --- a/internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go +++ b/internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go @@ -136,11 +136,11 @@ func IsRollingUpdate(deployment *apps.Deployment) bool { // MaxUnavailable returns the maximum unavailable pods a rolling deployment can take. func MaxUnavailable(deployment apps.Deployment) int32 { - if !IsRollingUpdate(&deployment) || *(deployment.Spec.Replicas) == 0 { + if !IsRollingUpdate(&deployment) || *deployment.Spec.Replicas == 0 { return int32(0) } // Error caught by validation - _, maxUnavailable, _ := ResolveFenceposts(deployment.Spec.Strategy.RollingUpdate.MaxSurge, deployment.Spec.Strategy.RollingUpdate.MaxUnavailable, *(deployment.Spec.Replicas)) + _, maxUnavailable, _ := ResolveFenceposts(deployment.Spec.Strategy.RollingUpdate.MaxSurge, deployment.Spec.Strategy.RollingUpdate.MaxUnavailable, *deployment.Spec.Replicas) if maxUnavailable > *deployment.Spec.Replicas { return *deployment.Spec.Replicas } diff --git a/internal/version/clientgo_test.go b/internal/version/clientgo_test.go index 624c669af..2304306e0 100644 --- a/internal/version/clientgo_test.go +++ b/internal/version/clientgo_test.go @@ -17,14 +17,20 @@ limitations under the License. package version import ( + "strings" "testing" "github.com/stretchr/testify/require" ) func TestK8sClientGoModVersion(t *testing.T) { - // Unfortunately, test builds don't include debug info / module info - // So we expect "K8sIOClientGoModVersion" to return error - _, err := K8sIOClientGoModVersion() - require.ErrorContains(t, err, "k8s.io/client-go not found in build info") + // Whether module info is embedded in a test binary depends on the Go + // release: builds before Go 1.27 omit it, so the lookup fails. Accept + // either outcome, but require that a successful lookup returns a version. + v, err := K8sIOClientGoModVersion() + if err != nil { + require.ErrorContains(t, err, "k8s.io/client-go not found in build info") + return + } + require.True(t, strings.HasPrefix(v, "v"), "expected a semver-like version, got %q", v) } diff --git a/pkg/chart/v2/loader/load.go b/pkg/chart/v2/loader/load.go index d7b125b9b..feaa28f12 100644 --- a/pkg/chart/v2/loader/load.go +++ b/pkg/chart/v2/loader/load.go @@ -153,7 +153,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { } fname := strings.TrimPrefix(f.Name, "charts/") - cname := strings.SplitN(fname, "/", 2)[0] + cname, _, _ := strings.Cut(fname, "/") subcharts[cname] = append(subcharts[cname], &archive.BufferedFile{Name: fname, ModTime: f.ModTime, Data: f.Data}) default: c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index 019cd1cda..ae5b224ce 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -46,7 +46,6 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, if c != "" { // retrieve value vv, err := cvals.PathValue(cpath + c) - var errNoValue common.ErrNoValue if err == nil { // if not bool, warn if bv, ok := vv.(bool); ok { @@ -54,7 +53,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, break } slog.Warn("returned non-bool value", "path", c, "chart", r.Name) - } else if !errors.As(err, &errNoValue) { + } else if _, ok := errors.AsType[common.ErrNoValue](err); !ok { // this is a real error slog.Warn("the method PathValue returned error", slog.Any("error", err)) } diff --git a/pkg/chart/v2/util/doc.go b/pkg/chart/v2/util/doc.go index ed741a83d..70dc7199f 100644 --- a/pkg/chart/v2/util/doc.go +++ b/pkg/chart/v2/util/doc.go @@ -42,4 +42,4 @@ into a Chart. When creating charts in memory, use the 'helm.sh/helm/pkg/chart' package directly. */ -package util // import "helm.sh/helm/v4/pkg/chart/v2/util" +package util diff --git a/pkg/chart/v2/util/save_test.go b/pkg/chart/v2/util/save_test.go index 1795226bc..6599addb2 100644 --- a/pkg/chart/v2/util/save_test.go +++ b/pkg/chart/v2/util/save_test.go @@ -298,13 +298,10 @@ func TestSaveDir(t *testing.T) { } func TestRepeatableSave(t *testing.T) { - tmp := t.TempDir() - defer os.RemoveAll(tmp) modTime := time.Date(2021, 9, 1, 20, 34, 58, 651387237, time.UTC) tests := []struct { name string chart *chart.Chart - want string }{ { name: "Package 1 file", @@ -325,7 +322,6 @@ func TestRepeatableSave(t *testing.T) { Schema: []byte("{\n \"title\": \"Values\"\n}"), SchemaModTime: modTime, }, - want: "63358874b93ea095c857cd66bcf5d0a4464840cf84a07547db744d81d6c5af59", }, { name: "Package 2 files", @@ -347,20 +343,26 @@ func TestRepeatableSave(t *testing.T) { Schema: []byte("{\n \"title\": \"Values\"\n}"), SchemaModTime: modTime, }, - want: "c2a43990053da788ad4e260d3b00d52a0b103ccc67ab9f48278a7b6dcfb2a4bd", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - // create package - dest := path.Join(tmp, "newdir") - where, err := Save(test.chart, dest) + // Package the same chart twice, into separate directories so the + // second Save does not simply overwrite the first. + first, err := Save(test.chart, t.TempDir()) + require.NoError(t, err, "Failed to save") + second, err := Save(test.chart, t.TempDir()) require.NoError(t, err, "Failed to save") - // get shasum for package - result, err := sha256Sum(where) + + firstSum, err := sha256Sum(first) require.NoError(t, err, "Failed to check shasum") - // assert that the package SHA is what we wanted. - assert.Equal(t, test.want, result, "FormatName() result = %v, want %v", result, test.want) + secondSum, err := sha256Sum(second) + require.NoError(t, err, "Failed to check shasum") + + // Packaging a chart must be reproducible. The digest itself is not + // pinned here because the bytes depend on the compression + // implementation of the Go release Helm is built with. + assert.Equal(t, firstSum, secondSum, "Save() is not repeatable") }) } } diff --git a/pkg/cmd/dependency_build.go b/pkg/cmd/dependency_build.go index 85c385670..89bcd8a1d 100644 --- a/pkg/cmd/dependency_build.go +++ b/pkg/cmd/dependency_build.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "path/filepath" @@ -82,8 +83,7 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { man.Verify = downloader.VerifyIfPossible } err = man.Build() - var e downloader.ErrRepoNotFound - if errors.As(err, &e) { + if e, ok := errors.AsType[downloader.ErrRepoNotFound](err); ok { return fmt.Errorf("%s. Please add the missing repos via 'helm repo add'", e.Error()) } return err @@ -97,9 +97,25 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { } // defaultKeyring returns the expanded path to the default keyring. +// +// The legacy pubring.gpg file is preferred and treated as absent only when +// stat fails with "not exist" — any other stat error (e.g. permissions) +// keeps the legacy path so the real error surfaces when the file is opened. +// If the legacy file is absent, the file-backed pubring.kbx path takes over +// under the same rule. When neither exists, the legacy path is returned so +// error messages keep pointing at the traditional default. func defaultKeyring() string { + gnupgHome := filepath.Join(homedir.HomeDir(), ".gnupg") if v, ok := os.LookupEnv("GNUPGHOME"); ok { - return filepath.Join(v, "pubring.gpg") + gnupgHome = v } - return filepath.Join(homedir.HomeDir(), ".gnupg", "pubring.gpg") + legacy := filepath.Join(gnupgHome, "pubring.gpg") + if _, err := os.Stat(legacy); !errors.Is(err, fs.ErrNotExist) { + return legacy + } + keybox := filepath.Join(gnupgHome, "pubring.kbx") + if _, err := os.Stat(keybox); !errors.Is(err, fs.ErrNotExist) { + return keybox + } + return legacy } diff --git a/pkg/cmd/dependency_build_test.go b/pkg/cmd/dependency_build_test.go index 1680c23a1..05e27076f 100644 --- a/pkg/cmd/dependency_build_test.go +++ b/pkg/cmd/dependency_build_test.go @@ -19,6 +19,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -140,3 +141,68 @@ func TestDependencyBuildCmdWithHelmV2Hash(t *testing.T) { t.Fatal(err) } } + +func TestDefaultKeyring(t *testing.T) { + touch := func(t *testing.T, path string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte("test"), 0o644)) + } + + tests := []struct { + name string + files []string + want string + }{ + {"legacy keyring only", []string{"pubring.gpg"}, "pubring.gpg"}, + {"keybox only", []string{"pubring.kbx"}, "pubring.kbx"}, + {"legacy keyring preferred over keybox", []string{"pubring.gpg", "pubring.kbx"}, "pubring.gpg"}, + {"neither present falls back to legacy path", nil, "pubring.gpg"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("GNUPGHOME", dir) + for _, f := range tt.files { + touch(t, filepath.Join(dir, f)) + } + assert.Equal(t, filepath.Join(dir, tt.want), defaultKeyring()) + }) + } + + t.Run("stat error other than not-exist keeps the legacy path", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory permissions are not enforced on Windows") + } + if os.Geteuid() == 0 { + t.Skip("root bypasses directory permissions") + } + + parent := t.TempDir() + dir := filepath.Join(parent, ".gnupg") + require.NoError(t, os.MkdirAll(dir, 0o700)) + touch(t, filepath.Join(dir, "pubring.kbx")) + t.Setenv("GNUPGHOME", dir) + + // Make the directory unsearchable so stat on both keyrings fails + // with a permission error rather than "not exist". + require.NoError(t, os.Chmod(dir, 0o000)) + t.Cleanup(func() { require.NoError(t, os.Chmod(dir, 0o700)) }) + + assert.Equal(t, filepath.Join(dir, "pubring.gpg"), defaultKeyring()) + }) + + t.Run("no GNUPGHOME falls back to the home directory", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("GNUPGHOME", home) // register restoration before unsetting + require.NoError(t, os.Unsetenv("GNUPGHOME")) + + gnupgDir := filepath.Join(home, ".gnupg") + require.NoError(t, os.MkdirAll(gnupgDir, 0o700)) + + assert.Equal(t, filepath.Join(gnupgDir, "pubring.gpg"), defaultKeyring()) + + touch(t, filepath.Join(gnupgDir, "pubring.kbx")) + assert.Equal(t, filepath.Join(gnupgDir, "pubring.kbx"), defaultKeyring()) + }) +} diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 67b1cf47b..00bb4be9d 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -482,8 +482,7 @@ func reformatExecErrorMsg(filename string, err error) error { // If it can parse out details from that error message such as the line number, template it failed on, // and error description, then it will construct a new error that displays these details in a structured way. // If there are issues with parsing the error message, the err passed into the function should return instead. - var execError template.ExecError - if !errors.As(err, &execError) { + if _, ok := errors.AsType[template.ExecError](err); !ok { return err } diff --git a/pkg/getter/httpgetter_test.go b/pkg/getter/httpgetter_test.go index aebb6f11c..e9432b195 100644 --- a/pkg/getter/httpgetter_test.go +++ b/pkg/getter/httpgetter_test.go @@ -447,7 +447,7 @@ func verifyInsecureSkipVerify(t *testing.T, g *HTTPGetter, caseName string, expe require.NoError(t, err) require.NotNil(t, returnVal, "Expected non nil value for http client") - transport := (returnVal.Transport).(*http.Transport) + transport := returnVal.Transport.(*http.Transport) gotValue := false if transport.TLSClientConfig != nil { gotValue = transport.TLSClientConfig.InsecureSkipVerify @@ -465,7 +465,7 @@ func TestDefaultHTTPTransportReuse(t *testing.T) { require.NotNil(t, httpClient1, "Expected non nil value for http client") - transport1 := (httpClient1.Transport).(*http.Transport) + transport1 := httpClient1.Transport.(*http.Transport) httpClient2, err := g.httpClient(g.opts) @@ -473,7 +473,7 @@ func TestDefaultHTTPTransportReuse(t *testing.T) { require.NotNil(t, httpClient2, "Expected non nil value for http client") - transport2 := (httpClient2.Transport).(*http.Transport) + transport2 := httpClient2.Transport.(*http.Transport) require.Equal(t, transport2, transport1, "Expected default transport to be reused") } @@ -489,7 +489,7 @@ func TestHTTPTransportOption(t *testing.T) { require.NotNil(t, httpClient1, "Expected non nil value for http client") - transport1 := (httpClient1.Transport).(*http.Transport) + transport1 := httpClient1.Transport.(*http.Transport) require.Equal(t, transport, transport1, "Expected transport option to be applied") @@ -499,7 +499,7 @@ func TestHTTPTransportOption(t *testing.T) { require.NotNil(t, httpClient2, "Expected non nil value for http client") - transport2 := (httpClient2.Transport).(*http.Transport) + transport2 := httpClient2.Transport.(*http.Transport) require.Equal(t, transport2, transport1, "Expected applied transport to be reused") diff --git a/pkg/kube/client_test.go b/pkg/kube/client_test.go index 86574a3e5..60f6b6fa1 100644 --- a/pkg/kube/client_test.go +++ b/pkg/kube/client_test.go @@ -79,7 +79,6 @@ func newPodWithStatus(name string, status v1.PodStatus, namespace string) v1.Pod ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: ns, - SelfLink: "/api/v1/namespaces/default/pods/" + name, }, Spec: v1.PodSpec{ Containers: []v1.Container{{ diff --git a/pkg/kube/ready.go b/pkg/kube/ready.go index 196fe922a..9beb35665 100644 --- a/pkg/kube/ready.go +++ b/pkg/kube/ready.go @@ -386,7 +386,7 @@ func (c *ReadyChecker) crdReady(crd apiextv1.CustomResourceDefinition) bool { func (c *ReadyChecker) statefulSetReady(sts *appsv1.StatefulSet) bool { // Verify the generation observed by the statefulSet controller matches the spec generation if sts.Status.ObservedGeneration != sts.Generation { - slog.Debug("StatefulSet is not ready, observedGeneration doest not match spec generation", "namespace", sts.GetNamespace(), "name", sts.GetName(), "actualGeneration", sts.Status.ObservedGeneration, "expectedGeneration", sts.Generation) + slog.Debug("StatefulSet is not ready, observedGeneration does not match spec generation", "namespace", sts.GetNamespace(), "name", sts.GetName(), "actualGeneration", sts.Status.ObservedGeneration, "expectedGeneration", sts.Generation) return false } @@ -440,7 +440,7 @@ func (c *ReadyChecker) statefulSetReady(sts *appsv1.StatefulSet) bool { func (c *ReadyChecker) replicationControllerReady(rc *corev1.ReplicationController) bool { // Verify the generation observed by the replicationController controller matches the spec generation if rc.Status.ObservedGeneration != rc.Generation { - slog.Debug("ReplicationController is not ready, observedGeneration doest not match spec generation", "namespace", rc.GetNamespace(), "name", rc.GetName(), "actualGeneration", rc.Status.ObservedGeneration, "expectedGeneration", rc.Generation) + slog.Debug("ReplicationController is not ready, observedGeneration does not match spec generation", "namespace", rc.GetNamespace(), "name", rc.GetName(), "actualGeneration", rc.Status.ObservedGeneration, "expectedGeneration", rc.Generation) return false } return true @@ -449,7 +449,7 @@ func (c *ReadyChecker) replicationControllerReady(rc *corev1.ReplicationControll func (c *ReadyChecker) replicaSetReady(rs *appsv1.ReplicaSet) bool { // Verify the generation observed by the replicaSet controller matches the spec generation if rs.Status.ObservedGeneration != rs.Generation { - slog.Debug("ReplicaSet is not ready, observedGeneration doest not match spec generation", "namespace", rs.GetNamespace(), "name", rs.GetName(), "actualGeneration", rs.Status.ObservedGeneration, "expectedGeneration", rs.Generation) + slog.Debug("ReplicaSet is not ready, observedGeneration does not match spec generation", "namespace", rs.GetNamespace(), "name", rs.GetName(), "actualGeneration", rs.Status.ObservedGeneration, "expectedGeneration", rs.Generation) return false } return true diff --git a/pkg/provenance/keybox.go b/pkg/provenance/keybox.go new file mode 100644 index 000000000..99f220b99 --- /dev/null +++ b/pkg/provenance/keybox.go @@ -0,0 +1,113 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provenance + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" +) + +// GnuPG 2.1+ can store file-backed public keys in a "keybox" (pubring.kbx), +// a container that interleaves OpenPGP keyblocks with GnuPG-specific +// metadata. It is not an OpenPGP packet stream, so it cannot be handed to +// openpgp.ReadKeyRing directly. A keybox is a sequence of blobs, each starting +// with: +// +// byte 0..3 u32 blob length, big endian, including this header +// byte 4 u8 blob type (0 empty, 1 header, 2 OpenPGP, 3 X.509) +// byte 5 u8 blob version +// +// The first blob is a header carrying the "KBXf" magic at offset 8. OpenPGP +// blobs (type 2) record where the raw keyblock lives inside the blob: +// +// byte 8..11 u32 keyblock offset, relative to the blob start +// byte 12..15 u32 keyblock length +// +// Reference: kbx/keybox-blob.c in the GnuPG source tree. +const ( + kbxBlobTypeHeader = 1 + kbxBlobTypeOpenPGP = 2 + + // kbxBlobFlagEphemeral marks a blob GnuPG considers not (yet) part of + // the keyring, e.g. written during an interrupted keyserver operation + // (KEYBOX_FLAG_BLOB_EPHEMERAL in kbx/keybox.h). GnuPG skips such blobs + // on every normal read (kbx/keybox-search.c), and so do we. + kbxBlobFlagEphemeral = 0x0002 + + // kbxMinBlobLen covers the length and type fields present in every blob. + kbxMinBlobLen = 5 + + // kbxOpenPGPHeaderLen is how much of an OpenPGP blob header must be + // present for the flags, keyblock offset and keyblock length fields to + // be readable. + kbxOpenPGPHeaderLen = 16 +) + +// isKeybox reports whether data looks like a GnuPG keybox (pubring.kbx) +// image, identified by the "KBXf" magic in the mandatory first header blob. +func isKeybox(data []byte) bool { + return len(data) >= 12 && data[4] == kbxBlobTypeHeader && string(data[8:12]) == "KBXf" +} + +// isArmored reports whether data looks like an ASCII-armored keyring, as +// produced by `gpg --export --armor`. +func isArmored(data []byte) bool { + return bytes.HasPrefix(bytes.TrimSpace(data), []byte("-----BEGIN PGP")) +} + +// keyboxPublicKeys extracts the OpenPGP keyblocks embedded in a keybox image +// and returns them concatenated, ready for openpgp.ReadKeyRing. Blobs of any +// other type (header, X.509, empty) are skipped, as are blobs flagged +// ephemeral, which GnuPG itself ignores when reading the keyring. Malformed +// input yields an error, never a panic. +func keyboxPublicKeys(data []byte) ([]byte, error) { + var keyblocks bytes.Buffer + for offset := 0; offset < len(data); { + rest := data[offset:] + if len(rest) < kbxMinBlobLen { + return nil, fmt.Errorf("truncated blob header at offset %d", offset) + } + blobLen := binary.BigEndian.Uint32(rest) + if blobLen < kbxMinBlobLen { + return nil, fmt.Errorf("invalid blob length %d at offset %d", blobLen, offset) + } + if uint64(blobLen) > uint64(len(rest)) { + return nil, fmt.Errorf("blob at offset %d has length %d exceeding the %d remaining bytes", offset, blobLen, len(rest)) + } + blob := rest[:blobLen] + if blob[4] == kbxBlobTypeOpenPGP { + if len(blob) < kbxOpenPGPHeaderLen { + return nil, fmt.Errorf("OpenPGP blob at offset %d is too short", offset) + } + flags := binary.BigEndian.Uint16(blob[6:]) + keyblockOffset := binary.BigEndian.Uint32(blob[8:]) + keyblockLen := binary.BigEndian.Uint32(blob[12:]) + if uint64(keyblockOffset)+uint64(keyblockLen) > uint64(len(blob)) { + return nil, fmt.Errorf("OpenPGP blob at offset %d has an out-of-range keyblock", offset) + } + if flags&kbxBlobFlagEphemeral == 0 { + keyblocks.Write(blob[keyblockOffset : keyblockOffset+keyblockLen]) + } + } + offset += int(blobLen) + } + if keyblocks.Len() == 0 { + return nil, errors.New("keybox contains no OpenPGP keys") + } + return keyblocks.Bytes(), nil +} diff --git a/pkg/provenance/keybox_test.go b/pkg/provenance/keybox_test.go new file mode 100644 index 000000000..e2133d45a --- /dev/null +++ b/pkg/provenance/keybox_test.go @@ -0,0 +1,188 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provenance + +import ( + "bytes" + "encoding/binary" + "os" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // testKeybox is a GnuPG keybox (pubring.kbx) containing the helm-test + // public key. Regenerate with testdata/regen-keyring-formats.sh. + testKeybox = "testdata/helm-test-key.kbx" + + // testMixedKeybox is a keybox containing the RSA and Ed25519 test keys. + testMixedKeybox = "testdata/helm-mixed-keyring.kbx" + + // testArmoredPubfile is the ASCII-armored export of the helm-test key. + testArmoredPubfile = "testdata/helm-test-key.asc" + + // testMultiBlockArmored is two concatenated single-key armored exports + // (cat key1.asc key2.asc), covering the RSA and Ed25519 test keys. + testMultiBlockArmored = "testdata/helm-mixed-keyring.asc" +) + +func TestIsKeybox(t *testing.T) { + tests := []struct { + name string + file string + want bool + }{ + {"keybox", testKeybox, true}, + {"mixed keybox", testMixedKeybox, true}, + {"legacy binary keyring", testPubfile, false}, + {"armored keyring", testArmoredPubfile, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := os.ReadFile(tt.file) + require.NoError(t, err) + assert.Equal(t, tt.want, isKeybox(data)) + }) + } + + t.Run("degenerate inputs", func(t *testing.T) { + assert.False(t, isKeybox(nil)) + assert.False(t, isKeybox([]byte{})) + assert.False(t, isKeybox([]byte("KBXf"))) + assert.False(t, isKeybox([]byte("garbage that is longer than twelve bytes"))) + }) +} + +func TestIsArmored(t *testing.T) { + tests := []struct { + name string + file string + want bool + }{ + {"armored keyring", testArmoredPubfile, true}, + {"legacy binary keyring", testPubfile, false}, + {"keybox", testKeybox, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := os.ReadFile(tt.file) + require.NoError(t, err) + assert.Equal(t, tt.want, isArmored(data)) + }) + } + + t.Run("degenerate inputs", func(t *testing.T) { + assert.False(t, isArmored(nil)) + assert.False(t, isArmored([]byte("not a key"))) + assert.True(t, isArmored([]byte("\n\t -----BEGIN PGP PUBLIC KEY BLOCK-----"))) + }) +} + +func TestKeyboxPublicKeys(t *testing.T) { + data, err := os.ReadFile(testKeybox) + require.NoError(t, err) + + keys, err := keyboxPublicKeys(data) + require.NoError(t, err) + + ring, err := openpgp.ReadKeyRing(bytes.NewReader(keys)) + require.NoError(t, err) + + require.Len(t, ring, 1) + _, ok := ring[0].Identities[testKeyName] + assert.True(t, ok, "expected keybox to contain %q", testKeyName) +} + +func TestKeyboxPublicKeysEphemeral(t *testing.T) { + // GnuPG flags in-progress key material as ephemeral (bit 0x0002 of the + // blob flags at blob offset 6) and hides it from every normal read; the + // parser must do the same. + setFlags := func(t *testing.T, data []byte, blobStart int, flags uint16) []byte { + t.Helper() + require.Equal(t, byte(kbxBlobTypeOpenPGP), data[blobStart+4]) + mutated := bytes.Clone(data) + binary.BigEndian.PutUint16(mutated[blobStart+6:], flags) + return mutated + } + + t.Run("all blobs ephemeral means no keys", func(t *testing.T) { + valid, err := os.ReadFile(testKeybox) + require.NoError(t, err) + + _, err = keyboxPublicKeys(setFlags(t, valid, 32, kbxBlobFlagEphemeral)) + assert.ErrorContains(t, err, "no OpenPGP keys") + }) + + t.Run("ephemeral blob is skipped, others kept", func(t *testing.T) { + valid, err := os.ReadFile(testMixedKeybox) + require.NoError(t, err) + + // Flag only the first OpenPGP blob (the RSA helm-test key). + keys, err := keyboxPublicKeys(setFlags(t, valid, 32, kbxBlobFlagEphemeral)) + require.NoError(t, err) + + ring, err := openpgp.ReadKeyRing(bytes.NewReader(keys)) + require.NoError(t, err) + + require.Len(t, ring, 1) + _, ok := ring[0].Identities[testKeyName] + assert.False(t, ok, "expected the ephemeral-flagged %q blob to be skipped", testKeyName) + }) +} + +func TestKeyboxPublicKeysMalformed(t *testing.T) { + valid, err := os.ReadFile(testKeybox) + require.NoError(t, err) + + // The mutations below rely on the fixture layout: a 32-byte header blob + // followed by an OpenPGP blob. + const blobStart = 32 + require.Greater(t, len(valid), blobStart+16) + require.Equal(t, byte(kbxBlobTypeOpenPGP), valid[blobStart+4]) + + mutate := func(offset int, value uint32) []byte { + data := bytes.Clone(valid) + binary.BigEndian.PutUint32(data[offset:], value) + return data + } + + tests := []struct { + name string + data []byte + }{ + {"header only, no keys", valid[:blobStart]}, + {"truncated inside blob header", valid[:blobStart+2]}, + {"truncated inside blob body", valid[:blobStart+16]}, + {"zero blob length", mutate(blobStart, 0)}, + {"blob length below minimum", mutate(blobStart, 4)}, + {"blob length past end of data", mutate(blobStart, uint32(len(valid))+1)}, + {"keyblock offset out of range", mutate(blobStart+8, uint32(len(valid)))}, + {"keyblock length out of range", mutate(blobStart+12, uint32(len(valid)))}, + {"keyblock offset overflow", mutate(blobStart+8, ^uint32(0))}, + {"keyblock length overflow", mutate(blobStart+12, ^uint32(0))}, + {"openpgp blob shorter than its header", append(bytes.Clone(valid[:blobStart]), 0, 0, 0, 8, kbxBlobTypeOpenPGP, 1, 0, 0)}, + {"empty input", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := keyboxPublicKeys(tt.data) + assert.Error(t, err) + }) + } +} diff --git a/pkg/provenance/sign.go b/pkg/provenance/sign.go index 90b7559e5..b4d5f7541 100644 --- a/pkg/provenance/sign.go +++ b/pkg/provenance/sign.go @@ -26,6 +26,7 @@ import ( "strings" "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" "github.com/ProtonMail/go-crypto/openpgp/clearsign" "github.com/ProtonMail/go-crypto/openpgp/packet" "sigs.k8s.io/yaml" @@ -357,13 +358,64 @@ func loadKey(keypath string) (*openpgp.Entity, error) { return openpgp.ReadEntity(pr) } +// loadKeyRing loads a keyring from ringpath, auto-detecting its storage +// format. Three formats are supported: +// +// - the legacy binary OpenPGP packet stream (GnuPG's pubring.gpg) +// - a GnuPG file-backed keybox (pubring.kbx), introduced in GnuPG 2.1 +// - an ASCII-armored keyring, as produced by `gpg --export --armor` func loadKeyRing(ringpath string) (openpgp.EntityList, error) { - f, err := os.Open(ringpath) + data, err := os.ReadFile(ringpath) if err != nil { return nil, err } - defer f.Close() - return openpgp.ReadKeyRing(f) + switch { + case isKeybox(data): + keys, err := keyboxPublicKeys(data) + if err != nil { + return nil, fmt.Errorf("reading keybox %s: %w", ringpath, err) + } + return openpgp.ReadKeyRing(bytes.NewReader(keys)) + case isArmored(data): + ring, err := loadArmoredKeyRing(data) + if err != nil { + return nil, fmt.Errorf("reading armored keyring %s: %w", ringpath, err) + } + return ring, nil + default: + return openpgp.ReadKeyRing(bytes.NewReader(data)) + } +} + +// loadArmoredKeyRing reads every armored block in data and merges the keys +// into a single keyring. Unlike openpgp.ReadArmoredKeyRing, which silently +// ignores everything after the first block, this handles keyrings assembled +// by concatenating exports (cat key1.asc key2.asc > keyring.asc), the same +// way GnuPG imports them. +func loadArmoredKeyRing(data []byte) (openpgp.EntityList, error) { + var ring openpgp.EntityList + r := bytes.NewReader(data) + for { + block, err := armor.Decode(r) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + if block.Type != openpgp.PublicKeyType && block.Type != openpgp.PrivateKeyType { + return nil, fmt.Errorf("expected a public or private key block, got %q", block.Type) + } + entities, err := openpgp.ReadKeyRing(block.Body) + if err != nil { + return nil, err + } + ring = append(ring, entities...) + } + if len(ring) == 0 { + return nil, errors.New("no keys found") + } + return ring, nil } // DigestFile calculates a SHA256 hash (like Docker) for a given file. diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go index ec6e6194b..f3e049999 100644 --- a/pkg/provenance/sign_test.go +++ b/pkg/provenance/sign_test.go @@ -16,6 +16,7 @@ limitations under the License. package provenance import ( + "bytes" "crypto" "errors" "io" @@ -24,6 +25,7 @@ import ( "strings" "testing" + "github.com/ProtonMail/go-crypto/openpgp/armor" pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors" "github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/stretchr/testify/assert" @@ -143,6 +145,72 @@ func TestLoadKeyRing(t *testing.T) { } } +func TestLoadKeyRingKeybox(t *testing.T) { + k, err := loadKeyRing(testKeybox) + require.NoError(t, err) + + require.Len(t, k, 1) + _, ok := k[0].Identities[testKeyName] + assert.True(t, ok, "expected %q in keybox keyring", testKeyName) +} + +func TestLoadKeyRingMixedKeybox(t *testing.T) { + k, err := loadKeyRing(testMixedKeybox) + require.NoError(t, err) + + require.Len(t, k, 2) + + names := make([]string, 0, len(k)) + hasEdDSA := false + for _, e := range k { + for n := range e.Identities { + names = append(names, n) + } + if e.PrimaryKey != nil && e.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoEdDSA { + hasEdDSA = true + } + } + assert.Contains(t, names, testKeyName) + assert.True(t, hasEdDSA, "expected an Ed25519 key in %s", testMixedKeybox) +} + +func TestLoadKeyRingArmored(t *testing.T) { + k, err := loadKeyRing(testArmoredPubfile) + require.NoError(t, err) + + require.Len(t, k, 1) + _, ok := k[0].Identities[testKeyName] + assert.True(t, ok, "expected %q in armored keyring", testKeyName) +} + +func TestLoadKeyRingArmoredMultiBlock(t *testing.T) { + // A keyring assembled by concatenating exports (cat a.asc b.asc) must + // load every block, the same way gpg --import does. + k, err := loadKeyRing(testMultiBlockArmored) + require.NoError(t, err) + + require.Len(t, k, 2) + names := make([]string, 0, len(k)) + for _, e := range k { + for n := range e.Identities { + names = append(names, n) + } + } + assert.Contains(t, names, testKeyName) +} + +func TestLoadArmoredKeyRingRejectsNonKeyBlocks(t *testing.T) { + var buf bytes.Buffer + w, err := armor.Encode(&buf, "PGP MESSAGE", nil) + require.NoError(t, err) + _, err = w.Write([]byte("not a key")) + require.NoError(t, err) + require.NoError(t, w.Close()) + + _, err = loadArmoredKeyRing(buf.Bytes()) + assert.ErrorContains(t, err, "expected a public or private key block") +} + func TestDigest(t *testing.T) { f, err := os.Open(testChartfile) require.NoError(t, err) @@ -318,6 +386,31 @@ func TestVerify(t *testing.T) { } } +// TestVerifyKeyboxKeyring mirrors TestVerify with the keyring loaded from a +// GnuPG keybox instead of the legacy binary format. +func TestVerifyKeyboxKeyring(t *testing.T) { + signer, err := NewFromKeyring(testKeybox, "") + require.NoError(t, err) + + archiveData, err := os.ReadFile(testChartfile) + require.NoError(t, err) + + sigData, err := os.ReadFile(testSigBlock) + require.NoError(t, err) + + ver, err := signer.Verify(archiveData, sigData, filepath.Base(testChartfile)) + require.NoError(t, err) + require.NotNil(t, ver.SignedBy) + _, ok := ver.SignedBy.Identities[testKeyName] + assert.True(t, ok, "expected chart to verify as signed by %q", testKeyName) + + tamperedSigData, err := os.ReadFile(testTamperedSigBlock) + require.NoError(t, err) + + _, err = signer.Verify(archiveData, tamperedSigData, filepath.Base(testChartfile)) + assert.Error(t, err, "expected tampered signature to fail against keybox keyring") +} + // readSumFile reads a file containing a sum generated by the UNIX shasum tool. func readSumFile(sumfile string) (string, error) { data, err := os.ReadFile(sumfile) diff --git a/pkg/provenance/testdata/helm-mixed-keyring.asc b/pkg/provenance/testdata/helm-mixed-keyring.asc new file mode 100644 index 000000000..acde8ef18 --- /dev/null +++ b/pkg/provenance/testdata/helm-mixed-keyring.asc @@ -0,0 +1,40 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFeWbZ4BCADIsgNRyVBSWJkbH6h3UWWXsA9ce1u+YKvaCYFSjIujKycTAqCC +P7qUV2Oj/4E2zUcOD1/8/meDwuTnNTLzdSw4ujtFlKRSw7zelQE6sxvID0KM0pQK +7AxDTXsm/7Afd/fg4WNW0/hcbeiNz4TVmSWAnbqeLg8o8eljR5QhTk47H6Glo4hV +raeLCKG77qm2qOQ/m38ec+L5n9iUpoZZu1S5RXPUIanV5pLlx2rQsooIQEdJbTRF +Iv3+2Nj/56PFVdrw7E5ARqCD4PpzzYV7uj8vKumOp+VivFj95Ze6DFENh1WWy336 +jQEj0uTolgOYeQ6AdJobwSPUeYXGV6Sf2vwXABEBAAG0XUhlbG0gVGVzdGluZyAo +VGhpcyBrZXkgc2hvdWxkIG9ubHkgYmUgdXNlZCBmb3IgdGVzdGluZy4gRE8gTk9U +IFRSVVNULikgPGhlbG0tdGVzdGluZ0BoZWxtLnNoPokBNwQTAQoAIQUCV5ZtngIb +AwULCQgHAwUVCgkICwUWAgMBAAIeAQIXgAAKCRCEO7+YH8GHYolFCAC0+ejT5dIX +0juRl5AdG6XAlEf9IrehkVlo0s5bG1Ucea9jjNRafmmHyMravrxZVI9zGFhaNpxA ++McnnWAO8lx5yTgnGW269qvFBsj5n0ItPewILQZjTEgwLYf1oc2qCFpk+8ovBnxZ +Kaz3J5QKqU09zLO2yALuI5FTCCUupJ3OGIrKvLJ4H2jsRk/5jgqIgqJotW/4Baza +4zemFdQHj9FPvq9sVeW5lyxM48i638GtslIgc0y2Yd/bfiy0tfIkskWz/kS1U6c9 +Utr3UPwpBqHjXnakmpK7GMGlp5eDXgr63BEdLKxJJWWxIys35fR19XZXcAELrwMh +zkTzB/dvI375uQENBFeWbZ4BCADB6a8oiF2royWbvwmujBxSmD68QWziNZbSCuRs +M4WM1iy69kspJHRwJEVIE5pkoVfV6tIcwQQ0VkJmq6Pf/pyKeMAXp0u8bp5K0mVE +Yio1/adiLM3kX1yleoXLNUb1gNvsk6Kp6BZCUNRmjKlbKxapJKHmL+opX8UoeLeE +KSgJNL8jvo3nRgg+wIPnVDoizz7y03F0k4SbgNGdoA/FtZs/vcSrEfzOLgI2RxQS +dj/ePkxg9TvQygUSYgAVbYlASw6XMmd/zTjkfD9qa0l3WSejTisn85b611WryDpA +iYkYC3GzK8g7S9VwkAbtr79cbG+1djlA4aHASpil+zR12yEhABEBAAGJAR8EGAEK +AAkFAleWbZ4CGwwACgkQhDu/mB/Bh2LbNQgAiOOMNzmKzY4A7/ra8ppaH9oL8XIa +WvInivOsx22K09PPuPVqi/ooBiRGyRqhdVS4ShPOoOTR5tOsdfPpEMTHnGQ1+jW+ +Tw5MXv3oMckWw3YEYptnuvon3wT4bOXLr+eYlY4Z1ONs+pAXwMiQ2zXNuKHpA2fR +HsF8Wyw57rCVn7K5nQgZCbVxasYlzvFGnmt/itSC5w/AnIvICDOWcoGFKTieqhME +IkCyvu+DNrMAumnD2fqF2olsM4IzCEPSMEQqJIGzZTtQTseS8NSDioRGnG1AJQ94 +BssQVmTh0/hlpoTqXY803lR5wb1fr3RRnkOu+lbEI6AAUj51j5TAAo/1dA== +=bvcV +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEaMzP2xYJKwYBBAHaRw8BAQdAXkL0KKTOZoMaM+6HsrkWv93zmLmnMiIoQvR2 +F+bjx7G0KEhlbG0gRWQyNTUxOSBUZXN0IDxoZWxtLWVkMjU1MTlAaGVsbS5zaD6I +mQQTFgoAQRYhBEy+dUwlWAxsh43nEfuFaBLOL8tkBQJozM/bAhsDBQkFo5qABQsJ +CAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEPuFaBLOL8tktBYA/2cclE0N+BZV +Wymk6XfwFMjErM6olWAfwA8DfAGQ55+lAQCqSa+Tkjd4dmToVHcb0QAm+zwklAY4 +e6qWgGiivn77AA== +=wJ7K +-----END PGP PUBLIC KEY BLOCK----- diff --git a/pkg/provenance/testdata/helm-mixed-keyring.kbx b/pkg/provenance/testdata/helm-mixed-keyring.kbx new file mode 100644 index 000000000..300711cdb Binary files /dev/null and b/pkg/provenance/testdata/helm-mixed-keyring.kbx differ diff --git a/pkg/provenance/testdata/helm-test-key.asc b/pkg/provenance/testdata/helm-test-key.asc new file mode 100644 index 000000000..2d069d9b7 --- /dev/null +++ b/pkg/provenance/testdata/helm-test-key.asc @@ -0,0 +1,30 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFeWbZ4BCADIsgNRyVBSWJkbH6h3UWWXsA9ce1u+YKvaCYFSjIujKycTAqCC +P7qUV2Oj/4E2zUcOD1/8/meDwuTnNTLzdSw4ujtFlKRSw7zelQE6sxvID0KM0pQK +7AxDTXsm/7Afd/fg4WNW0/hcbeiNz4TVmSWAnbqeLg8o8eljR5QhTk47H6Glo4hV +raeLCKG77qm2qOQ/m38ec+L5n9iUpoZZu1S5RXPUIanV5pLlx2rQsooIQEdJbTRF +Iv3+2Nj/56PFVdrw7E5ARqCD4PpzzYV7uj8vKumOp+VivFj95Ze6DFENh1WWy336 +jQEj0uTolgOYeQ6AdJobwSPUeYXGV6Sf2vwXABEBAAG0XUhlbG0gVGVzdGluZyAo +VGhpcyBrZXkgc2hvdWxkIG9ubHkgYmUgdXNlZCBmb3IgdGVzdGluZy4gRE8gTk9U +IFRSVVNULikgPGhlbG0tdGVzdGluZ0BoZWxtLnNoPokBNwQTAQoAIQUCV5ZtngIb +AwULCQgHAwUVCgkICwUWAgMBAAIeAQIXgAAKCRCEO7+YH8GHYolFCAC0+ejT5dIX +0juRl5AdG6XAlEf9IrehkVlo0s5bG1Ucea9jjNRafmmHyMravrxZVI9zGFhaNpxA ++McnnWAO8lx5yTgnGW269qvFBsj5n0ItPewILQZjTEgwLYf1oc2qCFpk+8ovBnxZ +Kaz3J5QKqU09zLO2yALuI5FTCCUupJ3OGIrKvLJ4H2jsRk/5jgqIgqJotW/4Baza +4zemFdQHj9FPvq9sVeW5lyxM48i638GtslIgc0y2Yd/bfiy0tfIkskWz/kS1U6c9 +Utr3UPwpBqHjXnakmpK7GMGlp5eDXgr63BEdLKxJJWWxIys35fR19XZXcAELrwMh +zkTzB/dvI375uQENBFeWbZ4BCADB6a8oiF2royWbvwmujBxSmD68QWziNZbSCuRs +M4WM1iy69kspJHRwJEVIE5pkoVfV6tIcwQQ0VkJmq6Pf/pyKeMAXp0u8bp5K0mVE +Yio1/adiLM3kX1yleoXLNUb1gNvsk6Kp6BZCUNRmjKlbKxapJKHmL+opX8UoeLeE +KSgJNL8jvo3nRgg+wIPnVDoizz7y03F0k4SbgNGdoA/FtZs/vcSrEfzOLgI2RxQS +dj/ePkxg9TvQygUSYgAVbYlASw6XMmd/zTjkfD9qa0l3WSejTisn85b611WryDpA +iYkYC3GzK8g7S9VwkAbtr79cbG+1djlA4aHASpil+zR12yEhABEBAAGJAR8EGAEK +AAkFAleWbZ4CGwwACgkQhDu/mB/Bh2LbNQgAiOOMNzmKzY4A7/ra8ppaH9oL8XIa +WvInivOsx22K09PPuPVqi/ooBiRGyRqhdVS4ShPOoOTR5tOsdfPpEMTHnGQ1+jW+ +Tw5MXv3oMckWw3YEYptnuvon3wT4bOXLr+eYlY4Z1ONs+pAXwMiQ2zXNuKHpA2fR +HsF8Wyw57rCVn7K5nQgZCbVxasYlzvFGnmt/itSC5w/AnIvICDOWcoGFKTieqhME +IkCyvu+DNrMAumnD2fqF2olsM4IzCEPSMEQqJIGzZTtQTseS8NSDioRGnG1AJQ94 +BssQVmTh0/hlpoTqXY803lR5wb1fr3RRnkOu+lbEI6AAUj51j5TAAo/1dA== +=bvcV +-----END PGP PUBLIC KEY BLOCK----- diff --git a/pkg/provenance/testdata/helm-test-key.kbx b/pkg/provenance/testdata/helm-test-key.kbx new file mode 100644 index 000000000..f2bf73fa5 Binary files /dev/null and b/pkg/provenance/testdata/helm-test-key.kbx differ diff --git a/pkg/provenance/testdata/regen-keyring-formats.sh b/pkg/provenance/testdata/regen-keyring-formats.sh new file mode 100755 index 000000000..c9cc95fc6 --- /dev/null +++ b/pkg/provenance/testdata/regen-keyring-formats.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Regenerate the file-backed keyring fixtures from the committed binary +# keyrings. Requires GnuPG 2.1+ (keybox support). +# +# helm-test-key.kbx keybox (pubring.kbx) containing the helm-test key +# helm-mixed-keyring.kbx keybox containing the RSA and Ed25519 test keys +# helm-test-key.asc ASCII-armored export of the helm-test key +# helm-mixed-keyring.asc two concatenated single-key armored exports +set -e + +GNUPGHOME=$(mktemp -d) +export GNUPGHOME +chmod 700 "$GNUPGHOME" +gpg --batch --no-tty --quiet --import helm-test-key.pub +cp "$GNUPGHOME/pubring.kbx" helm-test-key.kbx +gpg --batch --no-tty --export --armor helm-testing@helm.sh > helm-test-key.asc +rm -rf "$GNUPGHOME" + +GNUPGHOME=$(mktemp -d) +export GNUPGHOME +chmod 700 "$GNUPGHOME" +gpg --batch --no-tty --quiet --import helm-mixed-keyring.pub +cp "$GNUPGHOME/pubring.kbx" helm-mixed-keyring.kbx +gpg --batch --no-tty --export --armor helm-testing@helm.sh > helm-mixed-keyring.asc +gpg --batch --no-tty --export --armor helm-ed25519@helm.sh >> helm-mixed-keyring.asc +rm -rf "$GNUPGHOME" diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 4c7b9a6c8..672f5603a 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -231,7 +231,7 @@ type ( // Returns true if the host contains a path component (i.e., contains a '/'). func warnIfHostHasPath(host string) bool { if strings.Contains(host, "/") { - registryHost := strings.Split(host, "/")[0] + registryHost, _, _ := strings.Cut(host, "/") slog.Warn("registry login currently only supports registry hostname, not a repository path", "host", host, "suggested", registryHost) return true } @@ -322,6 +322,11 @@ func ensureTLSConfig(client *auth.Client, setConfig *tls.Config) (*tls.Config, e transport.TLSClientConfig = &tls.Config{} } + // Idle connections were established under the previous TLS configuration. + // Drop them so the settings being applied here take effect on the next + // request instead of being bypassed by a pooled connection. + transport.CloseIdleConnections() + return transport.TLSClientConfig, nil } diff --git a/pkg/repo/v1/repotest/server.go b/pkg/repo/v1/repotest/server.go index 06cdc9172..9eb32c47b 100644 --- a/pkg/repo/v1/repotest/server.go +++ b/pkg/repo/v1/repotest/server.go @@ -216,6 +216,19 @@ func (srv *OCIServer) RunWithReturn(t *testing.T, opts ...OCIServerOpt) *OCIServ go srv.ListenAndServe() + // NewOCIServer released the port it reserved so the registry could claim + // it, and ListenAndServe binds asynchronously. Wait for the port to accept + // connections so the login below does not race the listener coming up. + dialer := &net.Dialer{Timeout: time.Second} + require.Eventually(t, func() bool { + conn, err := dialer.DialContext(t.Context(), "tcp", srv.RegistryURL) + if err != nil { + return false + } + conn.Close() + return true + }, 30*time.Second, 20*time.Millisecond, "test registry never started listening on %s", srv.RegistryURL) + credentialsFile := filepath.Join(srv.Dir, "config.json") // init test client