diff --git a/internal/chart/v3/util/epoch.go b/internal/chart/v3/util/epoch.go new file mode 100644 index 000000000..1a9b293be --- /dev/null +++ b/internal/chart/v3/util/epoch.go @@ -0,0 +1,77 @@ +/* +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 overrides the ModTime on the chart and all of its +// entries to t, ensuring reproducible archives regardless of the original +// timestamps. 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 + } + c.ModTime = t + if c.Lock != nil { + c.Lock.Generated = t + } + if c.Schema != nil { + c.SchemaModTime = t + } + for _, f := range c.Raw { + f.ModTime = t + } + for _, f := range c.Templates { + f.ModTime = t + } + for _, f := range c.Files { + 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..b2d58b0c5 --- /dev/null +++ b/internal/chart/v3/util/epoch_test.go @@ -0,0 +1,267 @@ +/* +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 TestApplySourceDateEpochOverridesExisting(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, + Templates: []*common.File{ + {Name: "templates/test.yaml", ModTime: existing}, + }, + Files: []*common.File{ + {Name: "README.md", ModTime: existing}, + }, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(epoch) { + t.Errorf("Chart.ModTime = %v, want epoch %v", c.ModTime, epoch) + } + for _, f := range c.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("Template %s ModTime = %v, want epoch %v", f.Name, f.ModTime, epoch) + } + } + for _, f := range c.Files { + if !f.ModTime.Equal(epoch) { + t.Errorf("File %s ModTime = %v, want epoch %v", f.Name, f.ModTime, epoch) + } + } +} + +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) + } +} + +func TestApplySourceDateEpochDependencies(t *testing.T) { + epoch := time.Unix(1700000000, 0) + existing := time.Unix(1600000000, 0) + + dep := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "dep", + Version: "0.1.0", + }, + Templates: []*common.File{ + {Name: "templates/dep.yaml"}, + }, + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Version: "1.0.0", + }, + ModTime: existing, + Templates: []*common.File{ + {Name: "templates/main.yaml"}, + }, + } + c.AddDependency(dep) + + ApplySourceDateEpoch(c, epoch) + + // Parent chart had an existing ModTime, but it should be overridden. + if !c.ModTime.Equal(epoch) { + t.Errorf("parent Chart.ModTime = %v, want epoch %v", c.ModTime, epoch) + } + // Dependency had a zero ModTime, so it should be stamped. + if !dep.ModTime.Equal(epoch) { + t.Errorf("dep Chart.ModTime = %v, want %v", dep.ModTime, epoch) + } + for _, f := range dep.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("dep Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } +} + +func TestSaveWithSourceDateEpoch(t *testing.T) { + // End-to-end: parse SOURCE_DATE_EPOCH, apply to a chart with zero + // ModTimes, save as a tar archive, and verify every tar entry carries + // exactly the expected timestamp. + const epochStr = "1700000000" + want := time.Unix(1700000000, 0) + + t.Setenv("SOURCE_DATE_EPOCH", epochStr) + + epoch, err := ParseSourceDateEpoch() + if err != nil { + t.Fatalf("ParseSourceDateEpoch() error: %v", err) + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "epoch-test", + Version: "0.1.0", + }, + Values: map[string]any{"key": "value"}, + Schema: []byte(`{"title": "Values"}`), + Files: []*common.File{{Name: "README.md", Data: []byte("# test")}}, + Templates: []*common.File{{Name: "templates/test.yaml", Data: []byte("apiVersion: v1")}}, + } + + ApplySourceDateEpoch(c, epoch) + + tmp := t.TempDir() + where, err := Save(c, tmp) + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + headers, err := retrieveAllHeadersFromTar(where) + if err != nil { + t.Fatalf("failed to read tar: %v", err) + } + + if len(headers) == 0 { + t.Fatal("archive contains no entries") + } + + for _, h := range headers { + if !h.ModTime.Equal(want) { + t.Errorf("tar entry %q ModTime = %v, want %v", h.Name, h.ModTime, want) + } + } +} 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..67367a0c6 --- /dev/null +++ b/pkg/chart/v2/util/epoch.go @@ -0,0 +1,77 @@ +/* +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 overrides the ModTime on the chart and all of its +// entries to t, ensuring reproducible archives regardless of the original +// timestamps. 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 + } + c.ModTime = t + if c.Lock != nil { + c.Lock.Generated = t + } + if c.Schema != nil { + c.SchemaModTime = t + } + for _, f := range c.Raw { + f.ModTime = t + } + for _, f := range c.Templates { + f.ModTime = t + } + for _, f := range c.Files { + 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..89012d417 --- /dev/null +++ b/pkg/chart/v2/util/epoch_test.go @@ -0,0 +1,269 @@ +/* +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 TestApplySourceDateEpochOverridesExisting(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, + Templates: []*common.File{ + {Name: "templates/test.yaml", ModTime: existing}, + }, + Files: []*common.File{ + {Name: "README.md", ModTime: existing}, + }, + } + + ApplySourceDateEpoch(c, epoch) + + if !c.ModTime.Equal(epoch) { + t.Errorf("Chart.ModTime = %v, want epoch %v", c.ModTime, epoch) + } + for _, f := range c.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("Template %s ModTime = %v, want epoch %v", f.Name, f.ModTime, epoch) + } + } + for _, f := range c.Files { + if !f.ModTime.Equal(epoch) { + t.Errorf("File %s ModTime = %v, want epoch %v", f.Name, f.ModTime, epoch) + } + } +} + +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) + } +} + +func TestApplySourceDateEpochDependencies(t *testing.T) { + epoch := time.Unix(1700000000, 0) + existing := time.Unix(1600000000, 0) + + dep := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "dep", + Version: "0.1.0", + }, + Templates: []*common.File{ + {Name: "templates/dep.yaml"}, + }, + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "parent", + Version: "1.0.0", + }, + ModTime: existing, + Templates: []*common.File{ + {Name: "templates/main.yaml"}, + }, + } + c.AddDependency(dep) + + ApplySourceDateEpoch(c, epoch) + + // Parent chart had an existing ModTime, but it should be overridden. + if !c.ModTime.Equal(epoch) { + t.Errorf("parent Chart.ModTime = %v, want epoch %v", c.ModTime, epoch) + } + // Dependency had a zero ModTime, so it should be stamped. + if !dep.ModTime.Equal(epoch) { + t.Errorf("dep Chart.ModTime = %v, want %v", dep.ModTime, epoch) + } + for _, f := range dep.Templates { + if !f.ModTime.Equal(epoch) { + t.Errorf("dep Template %s ModTime = %v, want %v", f.Name, f.ModTime, epoch) + } + } +} + +func TestSaveWithSourceDateEpoch(t *testing.T) { + // End-to-end: parse SOURCE_DATE_EPOCH, apply to a chart with zero + // ModTimes, save as a tar archive, and verify every tar entry carries + // exactly the expected timestamp. + const epochStr = "1700000000" + want := time.Unix(1700000000, 0) + + t.Setenv("SOURCE_DATE_EPOCH", epochStr) + + epoch, err := ParseSourceDateEpoch() + if err != nil { + t.Fatalf("ParseSourceDateEpoch() error: %v", err) + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "epoch-test", + Version: "0.1.0", + }, + Values: map[string]any{"key": "value"}, + Schema: []byte(`{"title": "Values"}`), + Files: []*common.File{{Name: "README.md", Data: []byte("# test")}}, + Templates: []*common.File{{Name: "templates/test.yaml", Data: []byte("apiVersion: v1")}}, + } + + ApplySourceDateEpoch(c, epoch) + + tmp := t.TempDir() + where, err := Save(c, tmp) + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + headers, err := retrieveAllHeadersFromTar(where) + if err != nil { + t.Fatalf("failed to read tar: %v", err) + } + + if len(headers) == 0 { + t.Fatal("archive contains no entries") + } + + for _, h := range headers { + if !h.ModTime.Equal(want) { + t.Errorf("tar entry %q ModTime = %v, want %v", h.Name, h.ModTime, want) + } + } +} diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index fd4815cc4..f04738123 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -304,7 +304,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.Out) if err != nil { saveError = err break @@ -873,7 +873,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, out io.Writer) (string, error) { if !strings.HasPrefix(repo, "file://") { return "", fmt.Errorf("wrong format: chart %s repository %s", name, repo) } @@ -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(out, "WARNING: %v\n", epochErr) + } + chartutil.ApplySourceDateEpoch(ch, epoch) + _, err = chartutil.Save(ch, destPath) return ch.Metadata.Version, err }