From 55c0964bc757a3f7edb3297b677edeebdaec69bc 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 6fd968a56..dc8395154 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -271,8 +271,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 8c6e720b3..97441d760 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) } }