diff --git a/pkg/action/package.go b/pkg/action/package.go index 002203f40..b82194a03 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" @@ -49,6 +50,15 @@ type Package struct { Destination string DependencyUpdate bool + // SourceDateEpoch, when non-nil, overrides all tar entry modification times + // in the produced chart archive, and also overwrites the "generated" + // timestamp in Chart.lock (if present) since that value is both the lock + // file's own tar entry modtime and part of its marshaled YAML content. + // Set by callers that want reproducible builds. The environment variable + // SOURCE_DATE_EPOCH is the conventional way to supply this value from the + // CLI; reading that variable is the CLI's responsibility. + SourceDateEpoch *time.Time + RepositoryConfig string RepositoryCache string PlainHTTP bool @@ -121,6 +131,10 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) { dest = p.Destination } + if p.SourceDateEpoch != nil { + ch.StampModTimes(*p.SourceDateEpoch) + } + name, err := chartutil.Save(ch, dest) if err != nil { return "", fmt.Errorf("failed to save: %w", err) diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index ccba4f847..47460ef37 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -17,10 +17,14 @@ limitations under the License. package action import ( + "archive/tar" + "compress/gzip" "errors" + "io" "os" "path" "testing" + "time" "github.com/Masterminds/semver/v3" "github.com/stretchr/testify/require" @@ -169,3 +173,98 @@ func TestRun(t *testing.T) { require.Equal(t, "empty-0.1.0.tgz", filename) require.NoError(t, os.Remove(filename)) } + +func TestRunWithSourceDateEpoch(t *testing.T) { + chartPath := "testdata/charts/chart-with-schema" + // Use UTC so the comparison with tar header ModTime (always UTC) is straightforward. + epoch := time.Unix(1700000000, 0).UTC() + + client := NewPackage() + client.SourceDateEpoch = &epoch + + filename, err := client.Run(chartPath, nil) + require.NoError(t, err) + t.Cleanup(func() { os.Remove(filename) }) + + f, err := os.Open(filename) + require.NoError(t, err) + defer f.Close() + + // Check gzip header ModTime: helm leaves it at zero (deterministic by design). + gr, err := gzip.NewReader(f) + require.NoError(t, err) + require.True(t, gr.ModTime.IsZero(), "gzip header ModTime should be zero") + defer gr.Close() + + // Use Equal() rather than require.Equal so that timezone differences between + // the stored epoch (UTC) and the value tar.Reader reconstructs do not cause + // a false failure on non-UTC machines. + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + require.True(t, epoch.Equal(hdr.ModTime), "entry %s: got ModTime %v, want %v", hdr.Name, hdr.ModTime, epoch) + } +} + +func TestRunWithSourceDateEpochReproducible(t *testing.T) { + chartPath := "testdata/charts/chart-with-schema" + epoch := time.Unix(1700000000, 0) + + build := func() []byte { + t.Helper() + dir := t.TempDir() + client := NewPackage() + client.SourceDateEpoch = &epoch + client.Destination = dir + filename, err := client.Run(chartPath, nil) + require.NoError(t, err) + data, err := os.ReadFile(filename) + require.NoError(t, err) + return data + } + + first := build() + second := build() + require.Equal(t, first, second, "two builds with the same SOURCE_DATE_EPOCH must be byte-identical") +} + +func TestRunWithSourceDateEpochStampsLockGenerated(t *testing.T) { + chartPath := "testdata/charts/chart-with-lock" + epoch := time.Unix(1700000000, 0).UTC() + + client := NewPackage() + client.SourceDateEpoch = &epoch + + filename, err := client.Run(chartPath, nil) + require.NoError(t, err) + t.Cleanup(func() { os.Remove(filename) }) + + f, err := os.Open(filename) + require.NoError(t, err) + defer f.Close() + + gr, err := gzip.NewReader(f) + require.NoError(t, err) + defer gr.Close() + + found := false + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + if path.Base(hdr.Name) == "Chart.lock" { + found = true + // Without stamping Lock.Generated, this entry would keep the + // fixture's original 2016 timestamp instead of the epoch. + require.True(t, epoch.Equal(hdr.ModTime), "Chart.lock entry: got ModTime %v, want %v", hdr.ModTime, epoch) + } + } + require.True(t, found, "expected archive to contain a Chart.lock entry") +} diff --git a/pkg/action/testdata/charts/chart-with-lock/Chart.lock b/pkg/action/testdata/charts/chart-with-lock/Chart.lock new file mode 100644 index 000000000..824df0a40 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/Chart.lock @@ -0,0 +1,3 @@ +dependencies: [] +digest: sha256:0000000000000000000000000000000000000000000000000000000000000 +generated: "2016-01-01T00:00:00Z" diff --git a/pkg/action/testdata/charts/chart-with-lock/Chart.yaml b/pkg/action/testdata/charts/chart-with-lock/Chart.yaml new file mode 100644 index 000000000..2cb1b3557 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-lock/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: chart-with-lock +version: 0.1.0 +description: Test chart with a Chart.lock, used to verify SourceDateEpoch stamping diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index 9d798b4de..f214a839c 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -105,6 +105,37 @@ func (ch *Chart) Root() *Chart { // Dependencies are the charts that this chart depends on. func (ch *Chart) Dependencies() []*Chart { return ch.dependencies } +// StampModTimes recursively sets all file modification times in the chart and +// its dependencies to t. t is normalized to UTC and truncated to whole seconds +// because tar headers have second-level granularity and timezone-independent +// storage. This is used to produce reproducible archives when a build process +// supplies a fixed timestamp (e.g. via SOURCE_DATE_EPOCH). +// +// If Lock is set, its Generated field is also overwritten with t. This is not +// just a tar header change: Generated is marshaled into Chart.lock's own YAML +// content, so this stamping is visible to anyone who reads the lock file out +// of the produced archive. +func (ch *Chart) StampModTimes(t time.Time) { + t = t.UTC().Truncate(time.Second) + ch.ModTime = t + ch.SchemaModTime = t + if ch.Lock != nil { + ch.Lock.Generated = t + } + for _, f := range ch.Raw { + f.ModTime = t + } + for _, f := range ch.Templates { + f.ModTime = t + } + for _, f := range ch.Files { + f.ModTime = t + } + for _, dep := range ch.dependencies { + dep.StampModTimes(t) + } +} + // IsRoot determines if the chart is the root chart. func (ch *Chart) IsRoot() bool { return ch.parent == nil } diff --git a/pkg/cmd/package.go b/pkg/cmd/package.go index 46133d971..fa6091042 100644 --- a/pkg/cmd/package.go +++ b/pkg/cmd/package.go @@ -22,6 +22,8 @@ import ( "io" "os" "path/filepath" + "strconv" + "time" "github.com/spf13/cobra" @@ -59,6 +61,14 @@ func newPackageCmd(out io.Writer) *cobra.Command { if len(args) == 0 { return errors.New("need at least one argument, the path to the chart") } + if epochStr, ok := os.LookupEnv("SOURCE_DATE_EPOCH"); ok { + sec, err := strconv.ParseInt(epochStr, 10, 64) + if err != nil || sec < 0 { + return fmt.Errorf("invalid SOURCE_DATE_EPOCH %q: must be a non-negative Unix timestamp", epochStr) + } + t := time.Unix(sec, 0) + client.SourceDateEpoch = &t + } if client.Sign { if client.Key == "" { return errors.New("--key is required for signing a package") diff --git a/pkg/cmd/package_test.go b/pkg/cmd/package_test.go index db4a2523a..2a96feabf 100644 --- a/pkg/cmd/package_test.go +++ b/pkg/cmd/package_test.go @@ -16,12 +16,16 @@ limitations under the License. package cmd import ( + "archive/tar" + "compress/gzip" "fmt" + "io" "os" "path/filepath" "regexp" "strings" "testing" + "time" "helm.sh/helm/v4/internal/test/ensure" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -192,6 +196,68 @@ func TestSetAppVersion(t *testing.T) { } } +func TestPackageSourceDateEpoch(t *testing.T) { + chartToPackage := "testdata/testcharts/alpine" + + for _, tt := range []struct { + name string + envVal string + err string + }{ + {name: "non-numeric value", envVal: "not-a-number", err: "invalid SOURCE_DATE_EPOCH"}, + {name: "negative value", envVal: "-1", err: "invalid SOURCE_DATE_EPOCH"}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("SOURCE_DATE_EPOCH", tt.envVal) + cmd := fmt.Sprintf("package %s --destination=%s", chartToPackage, dir) + _, _, err := executeActionCommand(cmd) + if err == nil || !strings.Contains(err.Error(), tt.err) { + t.Fatalf("expected error containing %q, got %v", tt.err, err) + } + }) + } + + t.Run("valid value stamps archive entries", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("SOURCE_DATE_EPOCH", "1700000000") + cmd := fmt.Sprintf("package %s --destination=%s", chartToPackage, dir) + _, output, err := executeActionCommand(cmd) + if err != nil { + t.Logf("Output: %s", output) + t.Fatal(err) + } + + chartPath := filepath.Join(dir, "alpine-0.1.0.tgz") + f, err := os.Open(chartPath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + defer gr.Close() + + epoch := time.Unix(1700000000, 0).UTC() + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if !epoch.Equal(hdr.ModTime) { + t.Errorf("entry %s: got ModTime %v, want %v", hdr.Name, hdr.ModTime, epoch) + } + } + }) +} + func TestPackageFileCompletion(t *testing.T) { checkFileCompletion(t, "package", true) checkFileCompletion(t, "package mypath", true) // Multiple paths can be given