From 3e3c7a4ca7718668e451996c21d9b5f206af5d64 Mon Sep 17 00:00:00 2001 From: Lohit Kolluri Date: Wed, 27 May 2026 18:18:24 +0530 Subject: [PATCH] feat: honor SOURCE_DATE_EPOCH for chart archives Parse SOURCE_DATE_EPOCH in CLI commands and pass it through action.Package and downloader.Manager so library code stays free of environment reads. Invalid values are rejected at the CLI instead of being silently ignored. Signed-off-by: Lohit Kolluri --- internal/chart/v3/util/save_test.go | 49 +++++++++++++++++ internal/chart/v3/util/source_date_epoch.go | 54 +++++++++++++++++++ pkg/action/package.go | 7 +++ pkg/chart/v2/util/save_test.go | 49 +++++++++++++++++ pkg/chart/v2/util/source_date_epoch.go | 54 +++++++++++++++++++ pkg/cmd/dependency_build.go | 5 ++ pkg/cmd/dependency_update.go | 5 ++ pkg/cmd/install.go | 6 +++ pkg/cmd/package.go | 6 +++ pkg/cmd/source_date_epoch.go | 38 ++++++++++++++ pkg/cmd/source_date_epoch_test.go | 58 +++++++++++++++++++++ pkg/cmd/upgrade.go | 6 +++ pkg/downloader/manager.go | 11 +++- 13 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 internal/chart/v3/util/source_date_epoch.go create mode 100644 pkg/chart/v2/util/source_date_epoch.go create mode 100644 pkg/cmd/source_date_epoch.go create mode 100644 pkg/cmd/source_date_epoch_test.go diff --git a/internal/chart/v3/util/save_test.go b/internal/chart/v3/util/save_test.go index 34e7d898e..b499fb647 100644 --- a/internal/chart/v3/util/save_test.go +++ b/internal/chart/v3/util/save_test.go @@ -182,6 +182,55 @@ func TestSavePreservesTimestamps(t *testing.T) { } } +func TestSaveWithSourceDateEpoch(t *testing.T) { + epoch, err := ParseSourceDateEpochValue("1609459200") + if err != nil { + t.Fatalf("ParseSourceDateEpochValue() error: %v", err) + } + + tmp := t.TempDir() + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "ahab", + Version: "1.2.3", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + } + + ApplySourceDateEpoch(c, epoch) + where, err := Save(c, tmp) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + + allHeaders, err := retrieveAllHeadersFromTar(where) + if err != nil { + t.Fatalf("Failed to parse tar: %v", err) + } + + expected := epoch.Round(time.Second) + for _, header := range allHeaders { + if !header.ModTime.Equal(expected) { + t.Fatalf("Expected SOURCE_DATE_EPOCH timestamp %v, got %v for %q", expected, header.ModTime, header.Name) + } + } +} + +func findHeader(t *testing.T, headers []*tar.Header, name string) *tar.Header { + t.Helper() + for _, h := range headers { + if h.Name == name { + return h + } + } + t.Fatalf("Could not find tar header %q", name) + return nil +} + // We could refactor `load.go` to use this `retrieveAllHeadersFromTar` function // as well, so we are not duplicating components of the code which iterate // through the tar. diff --git a/internal/chart/v3/util/source_date_epoch.go b/internal/chart/v3/util/source_date_epoch.go new file mode 100644 index 000000000..da2fbe786 --- /dev/null +++ b/internal/chart/v3/util/source_date_epoch.go @@ -0,0 +1,54 @@ +package util + +import ( + "strconv" + "time" + + chart "helm.sh/helm/v4/internal/chart/v3" +) + +// ParseSourceDateEpochValue parses SOURCE_DATE_EPOCH per https://reproducible-builds.org/docs/source-date-epoch/. +func ParseSourceDateEpochValue(epochStr string) (time.Time, error) { + epoch, err := strconv.ParseInt(epochStr, 10, 64) + if err != nil { + return time.Time{}, err + } + if epoch < 0 { + return time.Time{}, strconv.ErrRange + } + return time.Unix(epoch, 0).UTC(), nil +} + +// ApplySourceDateEpoch sets timestamps on the chart (and dependencies) to epoch. +func ApplySourceDateEpoch(c *chart.Chart, epoch time.Time) { + applySourceDateEpoch(c, epoch) +} + +func applySourceDateEpoch(c *chart.Chart, epoch time.Time) { + c.ModTime = epoch + if len(c.Schema) > 0 { + c.SchemaModTime = epoch + } + if c.Lock != nil { + c.Lock.Generated = epoch + } + + for _, f := range c.Raw { + if f != nil { + f.ModTime = epoch + } + } + for _, f := range c.Templates { + if f != nil { + f.ModTime = epoch + } + } + for _, f := range c.Files { + if f != nil { + f.ModTime = epoch + } + } + for _, dep := range c.Dependencies() { + applySourceDateEpoch(dep, epoch) + } +} diff --git a/pkg/action/package.go b/pkg/action/package.go index 86426b412..642f3f872 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -23,6 +23,7 @@ import ( "os" "path/filepath" "syscall" + "time" "github.com/Masterminds/semver/v3" "golang.org/x/term" @@ -58,6 +59,8 @@ type Package struct { KeyFile string CaFile string InsecureSkipTLSVerify bool + // SourceDateEpoch, when set, normalizes chart timestamps for reproducible archives. + SourceDateEpoch *time.Time } const ( @@ -103,6 +106,10 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) { ch.Metadata.AppVersion = p.AppVersion } + if p.SourceDateEpoch != nil { + chartutil.ApplySourceDateEpoch(ch, *p.SourceDateEpoch) + } + if reqs := ac.MetaDependencies(); len(reqs) > 0 { if err := CheckDependencies(ch, reqs); err != nil { return "", err diff --git a/pkg/chart/v2/util/save_test.go b/pkg/chart/v2/util/save_test.go index 2f2b73efd..a2db9ead8 100644 --- a/pkg/chart/v2/util/save_test.go +++ b/pkg/chart/v2/util/save_test.go @@ -186,6 +186,55 @@ func TestSavePreservesTimestamps(t *testing.T) { } } +func TestSaveWithSourceDateEpoch(t *testing.T) { + epoch, err := ParseSourceDateEpochValue("1609459200") + if err != nil { + t.Fatalf("ParseSourceDateEpochValue() error: %v", err) + } + + tmp := t.TempDir() + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "ahab", + Version: "1.2.3", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + } + + ApplySourceDateEpoch(c, epoch) + where, err := Save(c, tmp) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + + allHeaders, err := retrieveAllHeadersFromTar(where) + if err != nil { + t.Fatalf("Failed to parse tar: %v", err) + } + + expected := epoch.Round(time.Second) + for _, header := range allHeaders { + if !header.ModTime.Equal(expected) { + t.Fatalf("Expected SOURCE_DATE_EPOCH timestamp %v, got %v for %q", expected, header.ModTime, header.Name) + } + } +} + +func findHeader(t *testing.T, headers []*tar.Header, name string) *tar.Header { + t.Helper() + for _, h := range headers { + if h.Name == name { + return h + } + } + t.Fatalf("Could not find tar header %q", name) + return nil +} + // We could refactor `load.go` to use this `retrieveAllHeadersFromTar` function // as well, so we are not duplicating components of the code which iterate // through the tar. diff --git a/pkg/chart/v2/util/source_date_epoch.go b/pkg/chart/v2/util/source_date_epoch.go new file mode 100644 index 000000000..172bc4abc --- /dev/null +++ b/pkg/chart/v2/util/source_date_epoch.go @@ -0,0 +1,54 @@ +package util + +import ( + "strconv" + "time" + + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +// ParseSourceDateEpochValue parses SOURCE_DATE_EPOCH per https://reproducible-builds.org/docs/source-date-epoch/. +func ParseSourceDateEpochValue(epochStr string) (time.Time, error) { + epoch, err := strconv.ParseInt(epochStr, 10, 64) + if err != nil { + return time.Time{}, err + } + if epoch < 0 { + return time.Time{}, strconv.ErrRange + } + return time.Unix(epoch, 0).UTC(), nil +} + +// ApplySourceDateEpoch sets timestamps on the chart (and dependencies) to epoch. +func ApplySourceDateEpoch(c *chart.Chart, epoch time.Time) { + applySourceDateEpoch(c, epoch) +} + +func applySourceDateEpoch(c *chart.Chart, epoch time.Time) { + c.ModTime = epoch + if len(c.Schema) > 0 { + c.SchemaModTime = epoch + } + if c.Lock != nil { + c.Lock.Generated = epoch + } + + for _, f := range c.Raw { + if f != nil { + f.ModTime = epoch + } + } + for _, f := range c.Templates { + if f != nil { + f.ModTime = epoch + } + } + for _, f := range c.Files { + if f != nil { + f.ModTime = epoch + } + } + for _, dep := range c.Dependencies() { + applySourceDateEpoch(dep, epoch) + } +} diff --git a/pkg/cmd/dependency_build.go b/pkg/cmd/dependency_build.go index b8ac16e60..90813eb55 100644 --- a/pkg/cmd/dependency_build.go +++ b/pkg/cmd/dependency_build.go @@ -55,6 +55,10 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { if len(args) > 0 { chartpath = filepath.Clean(args[0]) } + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return err + } registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { @@ -72,6 +76,7 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { RepositoryCache: settings.RepositoryCache, ContentCache: settings.ContentCache, Debug: settings.Debug, + SourceDateEpoch: sourceDateEpoch, } if client.Verify { man.Verify = downloader.VerifyIfPossible diff --git a/pkg/cmd/dependency_update.go b/pkg/cmd/dependency_update.go index 7f805c37b..11027ece9 100644 --- a/pkg/cmd/dependency_update.go +++ b/pkg/cmd/dependency_update.go @@ -58,6 +58,10 @@ func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Comma if len(args) > 0 { chartpath = filepath.Clean(args[0]) } + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return err + } registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { @@ -75,6 +79,7 @@ func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Comma RepositoryCache: settings.RepositoryCache, ContentCache: settings.ContentCache, Debug: settings.Debug, + SourceDateEpoch: sourceDateEpoch, } if client.Verify { man.Verify = downloader.VerifyAlways diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 67e2a9fab..adc7679bd 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -296,6 +296,11 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options slog.Warn("this chart is deprecated") } + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return nil, err + } + if req := ac.MetaDependencies(); len(req) > 0 { // If CheckDependencies returns an error, we have unfulfilled dependencies. // As of Helm 2.4.0, this is treated as a stopping condition: @@ -313,6 +318,7 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options ContentCache: settings.ContentCache, Debug: settings.Debug, RegistryClient: client.GetRegistryClient(), + SourceDateEpoch: sourceDateEpoch, } if err := man.Update(); err != nil { return nil, err diff --git a/pkg/cmd/package.go b/pkg/cmd/package.go index 14f9c8425..4387e5942 100644 --- a/pkg/cmd/package.go +++ b/pkg/cmd/package.go @@ -59,6 +59,11 @@ func newPackageCmd(out io.Writer) *cobra.Command { if len(args) == 0 { return errors.New("need at least one argument, the path to the chart") } + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return err + } + client.SourceDateEpoch = sourceDateEpoch if client.Sign { if client.Key == "" { return errors.New("--key is required for signing a package") @@ -101,6 +106,7 @@ func newPackageCmd(out io.Writer) *cobra.Command { RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, ContentCache: settings.ContentCache, + SourceDateEpoch: sourceDateEpoch, } if err := downloadManager.Update(); err != nil { diff --git a/pkg/cmd/source_date_epoch.go b/pkg/cmd/source_date_epoch.go new file mode 100644 index 000000000..31cdec8e9 --- /dev/null +++ b/pkg/cmd/source_date_epoch.go @@ -0,0 +1,38 @@ +/* +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 cmd + +import ( + "fmt" + "os" + "time" + + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" +) + +// sourceDateEpochFromEnv returns SOURCE_DATE_EPOCH when set, or nil when unset. +func sourceDateEpochFromEnv() (*time.Time, error) { + epochStr, ok := os.LookupEnv("SOURCE_DATE_EPOCH") + if !ok || epochStr == "" { + return nil, nil + } + epoch, err := chartutil.ParseSourceDateEpochValue(epochStr) + if err != nil { + return nil, fmt.Errorf("invalid SOURCE_DATE_EPOCH: %w", err) + } + return &epoch, nil +} diff --git a/pkg/cmd/source_date_epoch_test.go b/pkg/cmd/source_date_epoch_test.go new file mode 100644 index 000000000..f4c080ee4 --- /dev/null +++ b/pkg/cmd/source_date_epoch_test.go @@ -0,0 +1,58 @@ +/* +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 cmd + +import ( + "testing" + "time" +) + +func TestSourceDateEpochFromEnv(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "1609459200") + + got, err := sourceDateEpochFromEnv() + if err != nil { + t.Fatalf("sourceDateEpochFromEnv() error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil epoch") + } + want := time.Unix(1609459200, 0).UTC() + if !got.Equal(want) { + t.Fatalf("expected %v, got %v", want, *got) + } +} + +func TestSourceDateEpochFromEnvUnset(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "") + + got, err := sourceDateEpochFromEnv() + if err != nil { + t.Fatalf("sourceDateEpochFromEnv() error: %v", err) + } + if got != nil { + t.Fatalf("expected nil epoch, got %v", *got) + } +} + +func TestSourceDateEpochFromEnvInvalid(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "not-a-number") + + if _, err := sourceDateEpochFromEnv(); err == nil { + t.Fatal("expected error for invalid SOURCE_DATE_EPOCH") + } +} diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 43e19ab22..38793064b 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -193,6 +193,11 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } + sourceDateEpoch, err := sourceDateEpochFromEnv() + if err != nil { + return err + } + // Check chart dependencies to make sure all are present in /charts ch, err := loader.Load(chartPath) if err != nil { @@ -217,6 +222,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { RepositoryCache: settings.RepositoryCache, ContentCache: settings.ContentCache, Debug: settings.Debug, + SourceDateEpoch: sourceDateEpoch, } if err := man.Update(); err != nil { return err diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index 16459229d..272dba2a7 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -29,6 +29,7 @@ import ( "regexp" "strings" "sync" + "time" "github.com/Masterminds/semver/v3" "sigs.k8s.io/yaml" @@ -78,6 +79,8 @@ type Manager struct { // ContentCache is a location where a cache of charts can be stored ContentCache string + // SourceDateEpoch, when set, normalizes chart timestamps for reproducible archives. + SourceDateEpoch *time.Time } // Build rebuilds a local charts directory from a lockfile. @@ -304,7 +307,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { if m.Debug { fmt.Fprintf(m.Out, "Archiving %s from repo %s\n", dep.Name, dep.Repository) } - ver, err := tarFromLocalDir(m.ChartPath, dep.Name, dep.Repository, dep.Version, tmpPath) + ver, err := tarFromLocalDir(m.ChartPath, dep.Name, dep.Repository, dep.Version, tmpPath, m.SourceDateEpoch) if err != nil { saveError = err break @@ -873,7 +876,7 @@ func writeLock(chartpath string, lock *chart.Lock, legacyLockfile bool) error { } // archive a dep chart from local directory and save it into destPath -func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, error) { +func tarFromLocalDir(chartpath, name, repo, version, destPath string, sourceDateEpoch *time.Time) (string, error) { if !strings.HasPrefix(repo, "file://") { return "", fmt.Errorf("wrong format: chart %s repository %s", name, repo) } @@ -888,6 +891,10 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, e return "", err } + if sourceDateEpoch != nil { + chartutil.ApplySourceDateEpoch(ch, *sourceDateEpoch) + } + constraint, err := semver.NewConstraint(version) if err != nil { return "", fmt.Errorf("dependency %s has an invalid version/constraint format: %w", name, err)