From dad0b59d4300d879c5d6f7e08fd71329848c6ab8 Mon Sep 17 00:00:00 2001 From: Maxime Grenu Date: Tue, 10 Mar 2026 15:02:14 +0100 Subject: [PATCH] feat(util): respect SOURCE_DATE_EPOCH for reproducible chart archives Add ParseSourceDateEpoch and ApplySourceDateEpoch helpers to the v2 and v3 chart util packages. The caller (helm package, dependency build) now reads the environment variable, stamps every zero-valued ModTime field on the chart tree, and lets the existing tar writer pick up those times. This keeps Save() free from environment side effects: the epoch is parsed and applied at the call site. Entries that already carry a non-zero ModTime are left untouched. Signed-off-by: Maxime Wojtczak Signed-off-by: Maxime Grenu --- internal/chart/v3/util/epoch.go | 84 ++++++++++++++ internal/chart/v3/util/epoch_test.go | 158 +++++++++++++++++++++++++++ pkg/action/package.go | 7 ++ pkg/chart/v2/util/epoch.go | 84 ++++++++++++++ pkg/chart/v2/util/epoch_test.go | 158 +++++++++++++++++++++++++++ pkg/downloader/manager.go | 7 ++ 6 files changed, 498 insertions(+) create mode 100644 internal/chart/v3/util/epoch.go create mode 100644 internal/chart/v3/util/epoch_test.go create mode 100644 pkg/chart/v2/util/epoch.go create mode 100644 pkg/chart/v2/util/epoch_test.go diff --git a/internal/chart/v3/util/epoch.go b/internal/chart/v3/util/epoch.go new file mode 100644 index 000000000..f043be6d1 --- /dev/null +++ b/internal/chart/v3/util/epoch.go @@ -0,0 +1,84 @@ +/* +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 util + +import ( + "fmt" + "os" + "strconv" + "time" + + chart "helm.sh/helm/v4/internal/chart/v3" +) + +// ParseSourceDateEpoch reads the SOURCE_DATE_EPOCH environment variable and +// returns the corresponding time. It returns the zero time when the variable +// is not set or is set to the empty string. An error is returned when the +// value cannot be parsed or is negative. +// +// SOURCE_DATE_EPOCH is a standardised environment variable for reproducible +// builds; see https://reproducible-builds.org/docs/source-date-epoch/ +func ParseSourceDateEpoch() (time.Time, error) { + v, ok := os.LookupEnv("SOURCE_DATE_EPOCH") + if !ok || v == "" { + return time.Time{}, nil + } + epoch, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: %w", v, err) + } + if epoch < 0 { + return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: negative value", v) + } + return time.Unix(epoch, 0), nil +} + +// ApplySourceDateEpoch sets the ModTime on the chart and all of its entries +// that currently have a zero ModTime to t. It recurses into dependencies. +// When t is the zero time this is a no-op. +func ApplySourceDateEpoch(c *chart.Chart, t time.Time) { + if t.IsZero() { + return + } + if c.ModTime.IsZero() { + c.ModTime = t + } + if c.Lock != nil && c.Lock.Generated.IsZero() { + c.Lock.Generated = t + } + if c.Schema != nil && c.SchemaModTime.IsZero() { + c.SchemaModTime = t + } + for _, f := range c.Raw { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, f := range c.Templates { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, f := range c.Files { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, dep := range c.Dependencies() { + ApplySourceDateEpoch(dep, t) + } +} diff --git a/internal/chart/v3/util/epoch_test.go b/internal/chart/v3/util/epoch_test.go new file mode 100644 index 000000000..0db37b3d9 --- /dev/null +++ b/internal/chart/v3/util/epoch_test.go @@ -0,0 +1,158 @@ +/* +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 util + +import ( + "os" + "testing" + "time" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/internal/chart/v3" +) + +func TestParseSourceDateEpoch(t *testing.T) { + tests := []struct { + name string + value string + set bool + want time.Time + wantErr bool + }{ + { + name: "not set", + set: false, + want: time.Time{}, + }, + { + name: "valid epoch", + value: "1700000000", + set: true, + want: time.Unix(1700000000, 0), + }, + { + name: "invalid string", + value: "not-a-number", + set: true, + wantErr: true, + }, + { + name: "negative value", + value: "-1", + set: true, + wantErr: true, + }, + { + name: "zero", + value: "0", + set: true, + want: time.Unix(0, 0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.set { + t.Setenv("SOURCE_DATE_EPOCH", tt.value) + } else { + prevVal, wasSet := os.LookupEnv("SOURCE_DATE_EPOCH") + os.Unsetenv("SOURCE_DATE_EPOCH") + t.Cleanup(func() { + if wasSet { + os.Setenv("SOURCE_DATE_EPOCH", prevVal) + } + }) + } + + got, err := ParseSourceDateEpoch() + if (err != nil) != tt.wantErr { + t.Errorf("ParseSourceDateEpoch() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !got.Equal(tt.want) { + t.Errorf("ParseSourceDateEpoch() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplySourceDateEpoch(t *testing.T) { + epoch := time.Unix(1700000000, 0) + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + Templates: []*common.File{ + {Name: "templates/test.yaml"}, + }, + Files: []*common.File{ + {Name: "README.md"}, + }, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(epoch) { + t.Errorf("Chart.ModTime = %v, want %v", c.ModTime, epoch) + } + for _, f := range c.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } + for _, f := range c.Files { + if !f.ModTime.Equal(epoch) { + t.Errorf("File %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } +} + +func TestApplySourceDateEpochPreservesExisting(t *testing.T) { + epoch := time.Unix(1700000000, 0) + existing := time.Unix(1600000000, 0) + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + ModTime: existing, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(existing) { + t.Errorf("Chart.ModTime = %v, want existing %v", c.ModTime, existing) + } +} + +func TestApplySourceDateEpochZeroNoop(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + } + + ApplySourceDateEpoch(c, time.Time{}) + + if !c.ModTime.IsZero() { + t.Errorf("Chart.ModTime = %v, want zero", c.ModTime) + } +} diff --git a/pkg/action/package.go b/pkg/action/package.go index 86426b412..07cfebff8 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -109,6 +109,13 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) { } } + // Apply SOURCE_DATE_EPOCH for reproducible builds if set. + epoch, err := chartutil.ParseSourceDateEpoch() + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: %v\n", err) + } + chartutil.ApplySourceDateEpoch(ch, epoch) + var dest string if p.Destination == "." { // Save to the current working directory. diff --git a/pkg/chart/v2/util/epoch.go b/pkg/chart/v2/util/epoch.go new file mode 100644 index 000000000..f65e8c3ee --- /dev/null +++ b/pkg/chart/v2/util/epoch.go @@ -0,0 +1,84 @@ +/* +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 util + +import ( + "fmt" + "os" + "strconv" + "time" + + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +// ParseSourceDateEpoch reads the SOURCE_DATE_EPOCH environment variable and +// returns the corresponding time. It returns the zero time when the variable +// is not set or is set to the empty string. An error is returned when the +// value cannot be parsed or is negative. +// +// SOURCE_DATE_EPOCH is a standardised environment variable for reproducible +// builds; see https://reproducible-builds.org/docs/source-date-epoch/ +func ParseSourceDateEpoch() (time.Time, error) { + v, ok := os.LookupEnv("SOURCE_DATE_EPOCH") + if !ok || v == "" { + return time.Time{}, nil + } + epoch, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: %w", v, err) + } + if epoch < 0 { + return time.Time{}, fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: negative value", v) + } + return time.Unix(epoch, 0), nil +} + +// ApplySourceDateEpoch sets the ModTime on the chart and all of its entries +// that currently have a zero ModTime to t. It recurses into dependencies. +// When t is the zero time this is a no-op. +func ApplySourceDateEpoch(c *chart.Chart, t time.Time) { + if t.IsZero() { + return + } + if c.ModTime.IsZero() { + c.ModTime = t + } + if c.Lock != nil && c.Lock.Generated.IsZero() { + c.Lock.Generated = t + } + if c.Schema != nil && c.SchemaModTime.IsZero() { + c.SchemaModTime = t + } + for _, f := range c.Raw { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, f := range c.Templates { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, f := range c.Files { + if f.ModTime.IsZero() { + f.ModTime = t + } + } + for _, dep := range c.Dependencies() { + ApplySourceDateEpoch(dep, t) + } +} diff --git a/pkg/chart/v2/util/epoch_test.go b/pkg/chart/v2/util/epoch_test.go new file mode 100644 index 000000000..3df89ba07 --- /dev/null +++ b/pkg/chart/v2/util/epoch_test.go @@ -0,0 +1,158 @@ +/* +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 util + +import ( + "os" + "testing" + "time" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +func TestParseSourceDateEpoch(t *testing.T) { + tests := []struct { + name string + value string + set bool + want time.Time + wantErr bool + }{ + { + name: "not set", + set: false, + want: time.Time{}, + }, + { + name: "valid epoch", + value: "1700000000", + set: true, + want: time.Unix(1700000000, 0), + }, + { + name: "invalid string", + value: "not-a-number", + set: true, + wantErr: true, + }, + { + name: "negative value", + value: "-1", + set: true, + wantErr: true, + }, + { + name: "zero", + value: "0", + set: true, + want: time.Unix(0, 0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.set { + t.Setenv("SOURCE_DATE_EPOCH", tt.value) + } else { + prevVal, wasSet := os.LookupEnv("SOURCE_DATE_EPOCH") + os.Unsetenv("SOURCE_DATE_EPOCH") + t.Cleanup(func() { + if wasSet { + os.Setenv("SOURCE_DATE_EPOCH", prevVal) + } + }) + } + + got, err := ParseSourceDateEpoch() + if (err != nil) != tt.wantErr { + t.Errorf("ParseSourceDateEpoch() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !got.Equal(tt.want) { + t.Errorf("ParseSourceDateEpoch() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplySourceDateEpoch(t *testing.T) { + epoch := time.Unix(1700000000, 0) + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + Templates: []*common.File{ + {Name: "templates/test.yaml"}, + }, + Files: []*common.File{ + {Name: "README.md"}, + }, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(epoch) { + t.Errorf("Chart.ModTime = %v, want %v", c.ModTime, epoch) + } + for _, f := range c.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } + for _, f := range c.Files { + if !f.ModTime.Equal(epoch) { + t.Errorf("File %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } +} + +func TestApplySourceDateEpochPreservesExisting(t *testing.T) { + epoch := time.Unix(1700000000, 0) + existing := time.Unix(1600000000, 0) + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + ModTime: existing, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(existing) { + t.Errorf("Chart.ModTime = %v, want existing %v", c.ModTime, existing) + } +} + +func TestApplySourceDateEpochZeroNoop(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test", + Version: "0.1.0", + }, + } + + ApplySourceDateEpoch(c, time.Time{}) + + if !c.ModTime.IsZero() { + t.Errorf("Chart.ModTime = %v, want zero", c.ModTime) + } +} diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index 6043fbaaa..5104efbad 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -899,6 +899,13 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, e } if constraint.Check(v) { + // Apply SOURCE_DATE_EPOCH for reproducible builds if set. + epoch, epochErr := chartutil.ParseSourceDateEpoch() + if epochErr != nil { + fmt.Fprintf(os.Stderr, "WARNING: %v\n", epochErr) + } + chartutil.ApplySourceDateEpoch(ch, epoch) + _, err = chartutil.Save(ch, destPath) return ch.Metadata.Version, err }