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)