From a565ae40ca004769787d8357e27b7458b2cd3a16 Mon Sep 17 00:00:00 2001 From: Simone Tollardo Date: Fri, 19 Jun 2026 11:56:22 +0200 Subject: [PATCH 1/6] Add per-dependency content digest field to chart lock types Extend Dependency with an optional digest for tarball bytes in Chart.lock and document that the top-level lock digest remains metadata-only. Signed-off-by: Simone Tollardo --- internal/chart/v3/dependency.go | 10 +++++++++- pkg/chart/v2/dependency.go | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/chart/v3/dependency.go b/internal/chart/v3/dependency.go index 50ee5552e..1fb29e415 100644 --- a/internal/chart/v3/dependency.go +++ b/internal/chart/v3/dependency.go @@ -47,6 +47,11 @@ type Dependency struct { ImportValues []any `json:"import-values,omitempty" yaml:"import-values,omitempty"` // Alias usable alias to be used for the chart Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` + // Digest is the SHA256 hash of the dependency chart archive (sha256:...). + // + // This field is populated in Chart.lock to record the exact tarball bytes + // that were downloaded. It is omitted from Chart.yaml dependency entries. + Digest string `json:"digest,omitempty" yaml:"digest,omitempty"` } // Validate checks for common problems with the dependency datastructure in @@ -75,7 +80,10 @@ func (d *Dependency) Validate() error { type Lock struct { // Generated is the date the lock file was last generated. Generated time.Time `json:"generated"` - // Digest is a hash of the dependencies in Chart.yaml. + // Digest is a hash of Chart.yaml dependency constraints and the resolved + // lock metadata (name, version, repository). It detects drift between + // Chart.yaml and Chart.lock. Per-dependency tarball content is tracked + // separately via Dependency.Digest. Digest string `json:"digest"` // Dependencies is the list of dependencies that this lock file has locked. Dependencies []*Dependency `json:"dependencies"` diff --git a/pkg/chart/v2/dependency.go b/pkg/chart/v2/dependency.go index 5a92ef305..7a506ae06 100644 --- a/pkg/chart/v2/dependency.go +++ b/pkg/chart/v2/dependency.go @@ -47,6 +47,11 @@ type Dependency struct { ImportValues []any `json:"import-values,omitempty" yaml:"import-values,omitempty"` // Alias usable alias to be used for the chart Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` + // Digest is the SHA256 hash of the dependency chart archive (sha256:...). + // + // This field is populated in Chart.lock to record the exact tarball bytes + // that were downloaded. It is omitted from Chart.yaml dependency entries. + Digest string `json:"digest,omitempty" yaml:"digest,omitempty"` } // Validate checks for common problems with the dependency datastructure in @@ -75,7 +80,10 @@ func (d *Dependency) Validate() error { type Lock struct { // Generated is the date the lock file was last generated. Generated time.Time `json:"generated"` - // Digest is a hash of the dependencies in Chart.yaml. + // Digest is a hash of Chart.yaml dependency constraints and the resolved + // lock metadata (name, version, repository). It detects drift between + // Chart.yaml and Chart.lock. Per-dependency tarball content is tracked + // separately via Dependency.Digest. Digest string `json:"digest"` // Dependencies is the list of dependencies that this lock file has locked. Dependencies []*Dependency `json:"dependencies"` From 6411a69ebb3f400ce34bb9a5f514d30c6b4e4f79 Mon Sep 17 00:00:00 2001 From: Simone Tollardo Date: Fri, 19 Jun 2026 11:56:26 +0200 Subject: [PATCH 2/6] Exclude content digests from Chart.lock metadata hash HashReq now strips per-dependency digest fields so the top-level lock digest continues to detect Chart.yaml drift independently of tarball bytes. Signed-off-by: Simone Tollardo --- internal/resolver/resolver.go | 25 +++++++++++++++++++++++-- internal/resolver/resolver_test.go | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 5f0c5b148..1bf53b5a8 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -209,9 +209,13 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string // HashReq generates a hash of the dependencies. // // This should be used only to compare against another hash generated by this -// function. +// function. Per-dependency content digests are excluded; the top-level lock +// digest tracks Chart.yaml constraints and resolved metadata only. func HashReq(req, lock []*chart.Dependency) (string, error) { - data, err := json.Marshal([2][]*chart.Dependency{req, lock}) + data, err := json.Marshal([2][]*chart.Dependency{ + dependenciesForMetadataHash(req), + dependenciesForMetadataHash(lock), + }) if err != nil { return "", err } @@ -219,6 +223,23 @@ func HashReq(req, lock []*chart.Dependency) (string, error) { return "sha256:" + s, err } +// dependenciesForMetadataHash returns a copy of deps with content digests cleared. +func dependenciesForMetadataHash(deps []*chart.Dependency) []*chart.Dependency { + if len(deps) == 0 { + return nil + } + out := make([]*chart.Dependency, len(deps)) + for i, d := range deps { + if d == nil { + continue + } + cp := *d + cp.Digest = "" + out[i] = &cp + } + return out +} + // HashV2Req generates a hash of requirements generated in Helm v2. // // This should be used only to compare against another hash generated by the diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 1e33837a9..d9c395bbf 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -19,6 +19,9 @@ import ( "runtime" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/registry" ) @@ -308,3 +311,21 @@ func TestGetLocalPath(t *testing.T) { }) } } + +func TestHashReqIgnoresContentDigest(t *testing.T) { + req := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "http://localhost:8879/charts"}, + } + lockWithout := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "http://localhost:8879/charts"}, + } + lockWith := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "http://localhost:8879/charts", Digest: "sha256:abc123"}, + } + + h1, err := HashReq(req, lockWithout) + require.NoError(t, err) + h2, err := HashReq(req, lockWith) + require.NoError(t, err) + assert.Equal(t, h1, h2) +} From 7a750ab9e359d26c4559839d89e7897e97131ed9 Mon Sep 17 00:00:00 2001 From: Simone Tollardo Date: Fri, 19 Jun 2026 11:56:30 +0200 Subject: [PATCH 3/6] Add helpers for content-addressed dependency lock digests Provide shared utilities to format, compare, record, and detect changes in per-dependency tarball digests during dependency update and build. Signed-off-by: Simone Tollardo --- pkg/downloader/digest.go | 95 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 pkg/downloader/digest.go diff --git a/pkg/downloader/digest.go b/pkg/downloader/digest.go new file mode 100644 index 000000000..29d1a86b4 --- /dev/null +++ b/pkg/downloader/digest.go @@ -0,0 +1,95 @@ +/* +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 downloader + +import ( + "fmt" + "strings" + + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/provenance" +) + +// formatDigest returns a digest string in the canonical sha256: form. +func formatDigest(hex string) string { + if hex == "" { + return "" + } + if strings.HasPrefix(hex, "sha256:") { + return hex + } + return "sha256:" + hex +} + +// digestEqual compares two digests regardless of sha256: prefix. +func digestEqual(a, b string) bool { + if a == "" || b == "" { + return a == b + } + return stripDigestAlgorithm(a) == stripDigestAlgorithm(b) +} + +// digestFile computes the SHA256 digest of a chart archive file. +func digestFile(path string) (string, error) { + hex, err := provenance.DigestFile(path) + if err != nil { + return "", err + } + return formatDigest(hex), nil +} + +// recordDependencyDigest computes and stores the tarball digest on a lock dependency entry. +func recordDependencyDigest(dep *chart.Dependency, tarballPath string) error { + digest, err := digestFile(tarballPath) + if err != nil { + return err + } + if dep.Digest != "" && !digestEqual(dep.Digest, digest) { + return fmt.Errorf("chart.lock digest mismatch for %s-%s: expected %s, got %s", dep.Name, dep.Version, formatDigest(dep.Digest), digest) + } + dep.Digest = digest + return nil +} + +// lockNeedsWrite reports whether the new lock differs from the old lock in metadata +// or per-dependency content digests. +func lockNeedsWrite(old, updated *chart.Lock) bool { + if old == nil { + return true + } + if old.Digest != updated.Digest { + return true + } + return !dependencyDigestsEqual(old.Dependencies, updated.Dependencies) +} + +func dependencyDigestsEqual(a, b []*chart.Dependency) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] == nil || b[i] == nil { + if a[i] != b[i] { + return false + } + continue + } + if a[i].Name != b[i].Name || !digestEqual(a[i].Digest, b[i].Digest) { + return false + } + } + return true +} From 12200b00dfa347bd7cddeb5fe59572e5334cf80d Mon Sep 17 00:00:00 2001 From: Simone Tollardo Date: Fri, 19 Jun 2026 11:56:34 +0200 Subject: [PATCH 4/6] Warn when downloaded chart bytes differ from index digest Compare freshly downloaded tarball content against the repository index digest and emit a warning when upstream content does not match the index. Signed-off-by: Simone Tollardo --- pkg/downloader/chart_downloader.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go index 22c6c71a3..722cfdedc 100644 --- a/pkg/downloader/chart_downloader.go +++ b/pkg/downloader/chart_downloader.go @@ -85,6 +85,10 @@ type ChartDownloader struct { // Cache specifies the cache implementation to use. Cache Cache + + // ExpectedDigest is the digest from the repository index or OCI reference. + // When set, downloaded content is compared and a warning is emitted on mismatch. + ExpectedDigest string } // DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file. @@ -152,6 +156,10 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven if err != nil { return "", nil, err } + + if err := verifyDownloadedDigest(c.Out, ref, c.ExpectedDigest, hash, data); err != nil { + return "", nil, err + } } name := filepath.Base(u.Path) @@ -592,6 +600,27 @@ func loadRepoConfig(file string) (*repo.File, error) { return r, nil } +// verifyDownloadedDigest compares downloaded chart bytes against the digest from +// the repository index or OCI reference. A mismatch emits a warning to out. +func verifyDownloadedDigest(out io.Writer, ref, expectedDigest, resolvedDigest string, data *bytes.Buffer) error { + digest := expectedDigest + if digest == "" { + digest = resolvedDigest + } + if digest == "" || data == nil { + return nil + } + computed, err := provenance.Digest(bytes.NewReader(data.Bytes())) + if err != nil { + return err + } + if !digestEqual(digest, computed) { + fmt.Fprintf(out, "WARNING: digest mismatch for %s: repository index has sha256:%s, downloaded content has sha256:%s\n", + ref, stripDigestAlgorithm(digest), computed) + } + return nil +} + // stripDigestAlgorithm removes the algorithm prefix (e.g., "sha256:") from a digest string. // If no prefix is present, the original string is returned unchanged. func stripDigestAlgorithm(digest string) string { From 02d358d8cfb85cb3a67db053edbfe4199789b14e Mon Sep 17 00:00:00 2001 From: Simone Tollardo Date: Fri, 19 Jun 2026 11:56:39 +0200 Subject: [PATCH 5/6] Record and verify tarball digests in dependency update and build Populate per-dependency digests after download, rewrite Chart.lock when content changes, and fail dependency build when locked bytes do not match. Signed-off-by: Simone Tollardo --- pkg/downloader/manager.go | 38 ++++++++++++++++++++++++++-------- pkg/downloader/manager_test.go | 6 +++--- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index ac4f2207e..986a12bf8 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -211,9 +211,8 @@ func (m *Manager) Update() error { } lock.Digest = newDigest - // If the lock file hasn't changed, don't write a new one. - oldLock := c.Lock - if oldLock != nil && oldLock.Digest == lock.Digest { + // Rewrite the lock when metadata or per-dependency content digests changed. + if !lockNeedsWrite(c.Lock, lock) { return nil } @@ -273,6 +272,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { fmt.Fprintf(m.Out, "Saving %d charts\n", len(deps)) var saveError error churls := make(map[string]struct{}) + churlFiles := make(map[string]string) for _, dep := range deps { // No repository means the chart is in charts directory if dep.Repository == "" { @@ -310,12 +310,17 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { break } dep.Version = ver + tgzPath := filepath.Join(tmpPath, fmt.Sprintf("%s-%s.tgz", dep.Name, ver)) + if err := recordDependencyDigest(dep, tgzPath); err != nil { + saveError = err + break + } continue } // Any failure to resolve/download a chart should fail: // https://github.com/helm/helm/issues/1439 - churl, username, password, insecureSkipTLSVerify, passCredentialsAll, caFile, certFile, keyFile, err := m.findChartURL(dep.Name, dep.Version, dep.Repository, repos) + churl, username, password, insecureSkipTLSVerify, passCredentialsAll, caFile, certFile, keyFile, indexDigest, err := m.findChartURL(dep.Name, dep.Version, dep.Repository, repos) if err != nil { saveError = fmt.Errorf("could not find %s: %w", churl, err) break @@ -323,6 +328,12 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { if _, ok := churls[churl]; ok { fmt.Fprintf(m.Out, "Already downloaded %s from repo %s\n", dep.Name, dep.Repository) + if tgzPath, ok := churlFiles[churl]; ok { + if err := recordDependencyDigest(dep, tgzPath); err != nil { + saveError = err + break + } + } continue } @@ -343,6 +354,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { getter.WithInsecureSkipVerifyTLS(insecureSkipTLSVerify), getter.WithTLSClientConfig(certFile, keyFile, caFile), }, + ExpectedDigest: indexDigest, } version := "" @@ -356,12 +368,19 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { getter.WithTagName(version)) } - if _, _, err = dl.DownloadTo(churl, version, tmpPath); err != nil { + destfile, _, err := dl.DownloadTo(churl, version, tmpPath) + if err != nil { saveError = fmt.Errorf("could not download %s: %w", churl, err) break } + if err := recordDependencyDigest(dep, destfile); err != nil { + saveError = err + break + } + churls[churl] = struct{}{} + churlFiles[churl] = destfile } // TODO: this should probably be refactored to be a []error, so we can capture and provide more information rather than "last error wins". @@ -722,9 +741,9 @@ func (m *Manager) parallelRepoUpdate(repos []*repo.Entry) error { // repoURL is the repository to search // // If it finds a URL that is "relative", it will prepend the repoURL. -func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]*repo.ChartRepository) (url, username, password string, insecureSkipTLSVerify, passCredentialsAll bool, caFile, certFile, keyFile string, err error) { +func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]*repo.ChartRepository) (url, username, password string, insecureSkipTLSVerify, passCredentialsAll bool, caFile, certFile, keyFile, indexDigest string, err error) { if registry.IsOCI(repoURL) { - return fmt.Sprintf("%s/%s:%s", repoURL, name, version), "", "", false, false, "", "", "", nil + return fmt.Sprintf("%s/%s:%s", repoURL, name, version), "", "", false, false, "", "", "", "", nil } for _, cr := range repos { @@ -744,6 +763,7 @@ func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]* //nolint:nakedret return } + indexDigest = ve.Digest url, err = repo.ResolveReferenceURL(repoURL, ve.URLs[0]) if err != nil { //nolint:nakedret @@ -762,10 +782,10 @@ func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]* } url, err = repo.FindChartInRepoURL(repoURL, name, m.Getters, repo.WithChartVersion(version), repo.WithClientTLS(certFile, keyFile, caFile)) if err == nil { - return url, username, password, false, false, "", "", "", err + return url, username, password, false, false, "", "", "", "", err } err = fmt.Errorf("chart %s not found in %s: %w", name, repoURL, err) - return url, username, password, false, false, "", "", "", err + return url, username, password, false, false, "", "", "", "", err } // findEntryByName finds an entry in the chart repository whose name matches the given name. diff --git a/pkg/downloader/manager_test.go b/pkg/downloader/manager_test.go index 9e27f183f..66bee5976 100644 --- a/pkg/downloader/manager_test.go +++ b/pkg/downloader/manager_test.go @@ -71,7 +71,7 @@ func TestFindChartURL(t *testing.T) { version := "0.1.0" repoURL := "http://example.com/charts" - churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, err := m.findChartURL(name, version, repoURL, repos) + churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, _, err := m.findChartURL(name, version, repoURL, repos) if err != nil { t.Fatal(err) } @@ -96,7 +96,7 @@ func TestFindChartURL(t *testing.T) { version = "1.2.3" repoURL = "https://example-https-insecureskiptlsverify.com" - churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, err = m.findChartURL(name, version, repoURL, repos) + churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, _, err = m.findChartURL(name, version, repoURL, repos) if err != nil { t.Fatal(err) } @@ -121,7 +121,7 @@ func TestFindChartURL(t *testing.T) { version = "1.2.3" repoURL = "http://example.com/helm" - churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, err = m.findChartURL(name, version, repoURL, repos) + churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, _, err = m.findChartURL(name, version, repoURL, repos) if err != nil { t.Fatal(err) } From afdaa02f5da4f44b1c5e2080a17ee48bb8fde6fa Mon Sep 17 00:00:00 2001 From: Simone Tollardo Date: Fri, 19 Jun 2026 11:56:42 +0200 Subject: [PATCH 6/6] Add tests for content-addressed dependency locking Cover republish detection, stable content, build verification failures, legacy locks without digests, index digest warnings, and semver resolution. Signed-off-by: Simone Tollardo --- pkg/downloader/digest_test.go | 322 ++++++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 pkg/downloader/digest_test.go diff --git a/pkg/downloader/digest_test.go b/pkg/downloader/digest_test.go new file mode 100644 index 000000000..975d51adb --- /dev/null +++ b/pkg/downloader/digest_test.go @@ -0,0 +1,322 @@ +/* +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 downloader + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v4/internal/resolver" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/loader" + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/getter" + "helm.sh/helm/v4/pkg/provenance" + "helm.sh/helm/v4/pkg/repo/v1/repotest" +) + +func setupDigestTestManager(t *testing.T, chartPath string, srv *repotest.Server) (*Manager, *bytes.Buffer) { + t.Helper() + out := bytes.NewBuffer(nil) + contentCache := t.TempDir() + m := &Manager{ + ChartPath: chartPath, + Out: out, + Getters: getter.Providers{{Schemes: []string{"http", "https"}, New: getter.NewHTTPGetter}}, + RepositoryConfig: filepath.Join(srv.Root(), "repositories.yaml"), + RepositoryCache: srv.Root(), + ContentCache: contentCache, + } + return m, out +} + +func setupParentChart(t *testing.T, srv *repotest.Server, dep *chart.Dependency) (string, *chart.Chart) { + t.Helper() + dir := func(p ...string) string { + return filepath.Join(append([]string{srv.Root()}, p...)...) + } + parent := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{dep}, + }, + } + require.NoError(t, chartutil.SaveDir(parent, dir())) + return dir("parent"), parent +} + +func TestContentDigestUpdateOnRepublish(t *testing.T) { + srv := repotest.NewTempServer(t, repotest.WithChartSourceGlob("testdata/local-subchart-0.1.0.tgz")) + defer srv.Stop() + require.NoError(t, srv.LinkIndices()) + + parentPath, _ := setupParentChart(t, srv, &chart.Dependency{ + Name: "local-subchart", + Version: "0.1.0", + Repository: srv.URL(), + }) + + m, _ := setupDigestTestManager(t, parentPath, srv) + require.NoError(t, m.Update()) + + lockData, err := os.ReadFile(filepath.Join(parentPath, "Chart.lock")) + require.NoError(t, err) + var lock1 chart.Lock + require.NoError(t, yaml.Unmarshal(lockData, &lock1)) + firstDigest := lock1.Dependencies[0].Digest + require.NotEmpty(t, firstDigest) + assert.True(t, strings.HasPrefix(firstDigest, "sha256:")) + + // Republish same version with different chart bytes. + sub, err := loader.LoadDir(filepath.Join("testdata", "local-subchart")) + require.NoError(t, err) + sub.Metadata.Description = "modified content" + _, err = chartutil.Save(sub, srv.Root()) + require.NoError(t, err) + require.NoError(t, srv.CreateIndex()) + + require.NoError(t, m.Update()) + + lockData, err = os.ReadFile(filepath.Join(parentPath, "Chart.lock")) + require.NoError(t, err) + var lock2 chart.Lock + require.NoError(t, yaml.Unmarshal(lockData, &lock2)) + secondDigest := lock2.Dependencies[0].Digest + require.NotEmpty(t, secondDigest) + assert.NotEqual(t, firstDigest, secondDigest) + assert.Equal(t, lock1.Digest, lock2.Digest, "metadata digest should be unchanged when version pin is exact") +} + +func TestContentDigestStableOnSameBytes(t *testing.T) { + srv := repotest.NewTempServer(t, repotest.WithChartSourceGlob("testdata/local-subchart-0.1.0.tgz")) + defer srv.Stop() + require.NoError(t, srv.LinkIndices()) + + parentPath, _ := setupParentChart(t, srv, &chart.Dependency{ + Name: "local-subchart", + Version: "0.1.0", + Repository: srv.URL(), + }) + + m, _ := setupDigestTestManager(t, parentPath, srv) + require.NoError(t, m.Update()) + + lockBefore, err := os.ReadFile(filepath.Join(parentPath, "Chart.lock")) + require.NoError(t, err) + + require.NoError(t, m.Update()) + + lockAfter, err := os.ReadFile(filepath.Join(parentPath, "Chart.lock")) + require.NoError(t, err) + assert.Equal(t, string(lockBefore), string(lockAfter)) +} + +func TestBuildDigestMismatch(t *testing.T) { + srv := repotest.NewTempServer(t, repotest.WithChartSourceGlob("testdata/local-subchart-0.1.0.tgz")) + defer srv.Stop() + require.NoError(t, srv.LinkIndices()) + + parentPath, _ := setupParentChart(t, srv, &chart.Dependency{ + Name: "local-subchart", + Version: "0.1.0", + Repository: srv.URL(), + }) + + m, _ := setupDigestTestManager(t, parentPath, srv) + require.NoError(t, m.Update()) + + sub, err := loader.LoadDir(filepath.Join("testdata", "local-subchart")) + require.NoError(t, err) + sub.Metadata.Description = "tampered upstream content" + _, err = chartutil.Save(sub, srv.Root()) + require.NoError(t, err) + require.NoError(t, srv.CreateIndex()) + + err = m.Build() + require.Error(t, err) + assert.Contains(t, err.Error(), "chart.lock digest mismatch for local-subchart-0.1.0") +} + +func TestLegacyLockWithoutDigest(t *testing.T) { + srv := repotest.NewTempServer(t, repotest.WithChartSourceGlob("testdata/local-subchart-0.1.0.tgz")) + defer srv.Stop() + require.NoError(t, srv.LinkIndices()) + + parentPath, parent := setupParentChart(t, srv, &chart.Dependency{ + Name: "local-subchart", + Version: "0.1.0", + Repository: srv.URL(), + }) + + legacyLock := &chart.Lock{ + Generated: time.Now(), + Dependencies: []*chart.Dependency{{ + Name: "local-subchart", + Version: "0.1.0", + Repository: srv.URL(), + }}, + } + digest, err := resolver.HashReq(parent.Metadata.Dependencies, legacyLock.Dependencies) + require.NoError(t, err) + legacyLock.Digest = digest + lockData, err := yaml.Marshal(legacyLock) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(parentPath, "Chart.lock"), lockData, 0644)) + + m, _ := setupDigestTestManager(t, parentPath, srv) + require.NoError(t, m.Build()) + + require.NoError(t, m.Update()) + + lockData, err = os.ReadFile(filepath.Join(parentPath, "Chart.lock")) + require.NoError(t, err) + var updated chart.Lock + require.NoError(t, yaml.Unmarshal(lockData, &updated)) + require.NotEmpty(t, updated.Dependencies[0].Digest) +} + +func TestIndexDigestMismatchWarning(t *testing.T) { + srv := repotest.NewTempServer(t, repotest.WithChartSourceGlob("testdata/local-subchart-0.1.0.tgz")) + defer srv.Stop() + require.NoError(t, srv.LinkIndices()) + + depArchive := filepath.Join(srv.Root(), "local-subchart-0.1.0.tgz") + realDigest, err := provenance.DigestFile(depArchive) + require.NoError(t, err) + + dest := t.TempDir() + contentCache := t.TempDir() + out := bytes.NewBuffer(nil) + dl := ChartDownloader{ + Out: out, + RepositoryConfig: filepath.Join(srv.Root(), "repositories.yaml"), + RepositoryCache: srv.Root(), + ContentCache: contentCache, + Getters: getter.Providers{{Schemes: []string{"http", "https"}, New: getter.NewHTTPGetter}}, + ExpectedDigest: strings.Repeat("a", len(realDigest)), + } + + _, _, err = dl.DownloadTo(srv.URL()+"/local-subchart-0.1.0.tgz", "0.1.0", dest) + require.NoError(t, err) + assert.Contains(t, out.String(), "WARNING: digest mismatch") +} + +func TestSemverRangeRecordsContentDigest(t *testing.T) { + srv := repotest.NewTempServer(t, repotest.WithChartSourceGlob("testdata/local-subchart-0.1.0.tgz")) + defer srv.Stop() + + sub, err := loader.LoadDir(filepath.Join("testdata", "local-subchart")) + require.NoError(t, err) + sub.Metadata.Version = "0.2.0" + _, err = chartutil.Save(sub, srv.Root()) + require.NoError(t, err) + require.NoError(t, srv.CreateIndex()) + require.NoError(t, srv.LinkIndices()) + + parentPath, _ := setupParentChart(t, srv, &chart.Dependency{ + Name: "local-subchart", + Version: ">=0.1.0", + Repository: srv.URL(), + }) + + m, _ := setupDigestTestManager(t, parentPath, srv) + require.NoError(t, m.Update()) + + lockData, err := os.ReadFile(filepath.Join(parentPath, "Chart.lock")) + require.NoError(t, err) + var lock chart.Lock + require.NoError(t, yaml.Unmarshal(lockData, &lock)) + require.Len(t, lock.Dependencies, 1) + assert.Equal(t, "0.2.0", lock.Dependencies[0].Version) + require.NotEmpty(t, lock.Dependencies[0].Digest) + + tgzPath := filepath.Join(parentPath, "charts", "local-subchart-0.2.0.tgz") + require.FileExists(t, tgzPath) + got, err := provenance.DigestFile(tgzPath) + require.NoError(t, err) + assert.Equal(t, "sha256:"+got, lock.Dependencies[0].Digest) +} + +func TestLockNeedsWrite(t *testing.T) { + metaDigest := "sha256:abc" + old := &chart.Lock{ + Digest: metaDigest, + Dependencies: []*chart.Dependency{{ + Name: "foo", + Version: "1.0.0", + Digest: "sha256:111", + }}, + } + same := &chart.Lock{ + Digest: metaDigest, + Dependencies: []*chart.Dependency{{ + Name: "foo", + Version: "1.0.0", + Digest: "sha256:111", + }}, + } + changedContent := &chart.Lock{ + Digest: metaDigest, + Dependencies: []*chart.Dependency{{ + Name: "foo", + Version: "1.0.0", + Digest: "sha256:222", + }}, + } + assert.False(t, lockNeedsWrite(old, same)) + assert.True(t, lockNeedsWrite(old, changedContent)) + assert.True(t, lockNeedsWrite(nil, same)) + + withNil := &chart.Lock{ + Digest: metaDigest, + Dependencies: []*chart.Dependency{ + nil, + {Name: "foo", Version: "1.0.0", Digest: "sha256:111"}, + }, + } + assert.True(t, lockNeedsWrite(old, withNil)) + assert.True(t, dependencyDigestsEqual(withNil.Dependencies, withNil.Dependencies)) +} + +func TestLoadLockWithDigestField(t *testing.T) { + srv := repotest.NewTempServer(t, repotest.WithChartSourceGlob("testdata/local-subchart-0.1.0.tgz")) + defer srv.Stop() + require.NoError(t, srv.LinkIndices()) + + parentPath, _ := setupParentChart(t, srv, &chart.Dependency{ + Name: "local-subchart", + Version: "0.1.0", + Repository: srv.URL(), + }) + + m, _ := setupDigestTestManager(t, parentPath, srv) + require.NoError(t, m.Update()) + + c, err := loader.LoadDir(parentPath) + require.NoError(t, err) + require.NotNil(t, c.Lock) + require.NotEmpty(t, c.Lock.Dependencies[0].Digest) +}