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 }