From 9b457ea0321515bca04336f974fa25eee53a3dde Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Fri, 29 May 2026 23:57:56 +0300 Subject: [PATCH] fix: normalize epoch to UTC/truncate before stamping tar modtimes Tar headers have second-level granularity and store timezone-independent Unix timestamps. Normalize SourceDateEpoch with .UTC().Truncate(time.Second) so callers passing sub-second or non-UTC values still get deterministic output. Also verify in the test that gzip header ModTime is zero (helm design) and compare tar entry ModTimes with time.Equal() since tar.Reader returns local time. Signed-off-by: Ilya Kiselev --- pkg/action/package.go | 3 +++ pkg/action/package_test.go | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/action/package.go b/pkg/action/package.go index ebacd4da0..d3e8d1a52 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -267,8 +267,11 @@ func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) } // stampModTimes recursively sets all file modification times in a chart to t. +// t is normalized to UTC and truncated to whole seconds before use because tar +// headers have second-level granularity and timezone-independent storage. // This is used to produce reproducible archives when SourceDateEpoch is set. func stampModTimes(c *chart.Chart, t time.Time) { + t = t.UTC().Truncate(time.Second) c.ModTime = t c.SchemaModTime = t for _, f := range c.Raw { diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index 2c8749a2c..89f6385b3 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -177,7 +177,8 @@ func TestRun(t *testing.T) { func TestRunWithSourceDateEpoch(t *testing.T) { chartPath := "testdata/charts/chart-with-schema" - epoch := time.Unix(1700000000, 0) + // Use UTC so the comparison with tar header ModTime (always UTC) is straightforward. + epoch := time.Unix(1700000000, 0).UTC() client := NewPackage() client.SourceDateEpoch = &epoch @@ -186,15 +187,18 @@ func TestRunWithSourceDateEpoch(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { os.Remove(filename) }) - // All tar entry ModTimes must equal the epoch. 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.Header.ModTime.IsZero(), "gzip header ModTime should be zero") defer gr.Close() + // All tar entry ModTimes must represent the same instant as epoch. + // tar.Reader returns ModTime in local timezone, so use Equal() not require.Equal. tr := tar.NewReader(gr) for { hdr, err := tr.Next() @@ -202,7 +206,7 @@ func TestRunWithSourceDateEpoch(t *testing.T) { break } require.NoError(t, err) - require.Equal(t, epoch, hdr.ModTime, "expected epoch ModTime for entry %s", hdr.Name) + require.True(t, epoch.Equal(hdr.ModTime), "entry %s: got ModTime %v, want %v", hdr.Name, hdr.ModTime, epoch) } }