diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index 421f34b33..d216d8341 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -17,9 +17,13 @@ limitations under the License. package action import ( + "archive/tar" + "compress/gzip" + "io" "os" "path" "testing" + "time" "github.com/Masterminds/semver/v3" "github.com/stretchr/testify/assert" @@ -144,3 +148,58 @@ func TestRun(t *testing.T) { require.Equal(t, "empty-0.1.0.tgz", filename) require.NoError(t, os.Remove(filename)) } + +// TestRunWithSourceDateEpochStampsLockGenerated verifies that packaging a chart +// that has a Chart.lock stamps both the tar entry modtime and the marshaled +// generated: field in Chart.lock to the given epoch. +// +// This guards against the normalization regression where a caller supplying a +// local-timezone or sub-second time.Time would produce a non-reproducible +// generated: value even when the same SOURCE_DATE_EPOCH is used on different +// machines. +func TestRunWithSourceDateEpochStampsLockGenerated(t *testing.T) { + // Use a non-local, non-UTC timezone and sub-second precision to confirm + // normalization: without UTC().Truncate(time.Second) the generated: field + // would contain a timezone offset or fractional seconds. + loc := time.FixedZone("UTC+3", 3*60*60) + rawEpoch := time.Unix(1700000000, 123456789).In(loc) + epoch := rawEpoch.UTC().Truncate(time.Second) + + client := NewPackage() + client.SourceDateEpoch = &rawEpoch + + filename, err := client.Run("testdata/charts/chart-with-lock", 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" { + continue + } + found = true + require.True(t, epoch.Equal(hdr.ModTime), + "Chart.lock tar modtime: got %v, want %v", hdr.ModTime, epoch) + + raw, err := io.ReadAll(tr) + require.NoError(t, err) + wantGenerated := epoch.Format(time.RFC3339) + require.Contains(t, string(raw), wantGenerated, + "Chart.lock generated: field should contain normalized UTC timestamp") + } + 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 9772754ce..ccfe84f51 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -177,34 +177,41 @@ func (ch *Chart) CRDObjects() []CRD { return crds } -// StampModTimes sets timestamps on the chart (and dependencies) to epoch. -// This is used for reproducible builds via SOURCE_DATE_EPOCH. -func (ch *Chart) StampModTimes(epoch time.Time) { - ch.ModTime = epoch +// StampModTimes sets timestamps on the chart (and dependencies) to t, +// normalized to UTC and truncated to whole seconds. +// +// Normalization is required because Chart.lock's generated: field is written +// by yaml.Marshal from Lock.Generated. Without UTC/truncate, a caller +// supplying a local-zone or sub-second time.Time produces a generated: value +// with a timezone offset or fractional seconds, making the lock file content +// non-reproducible across machines even when the same SOURCE_DATE_EPOCH is used. +func (ch *Chart) StampModTimes(t time.Time) { + t = t.UTC().Truncate(time.Second) + ch.ModTime = t if len(ch.Schema) > 0 { - ch.SchemaModTime = epoch + ch.SchemaModTime = t } if ch.Lock != nil { - ch.Lock.Generated = epoch + ch.Lock.Generated = t } for _, f := range ch.Raw { if f != nil { - f.ModTime = epoch + f.ModTime = t } } for _, f := range ch.Templates { if f != nil { - f.ModTime = epoch + f.ModTime = t } } for _, f := range ch.Files { if f != nil { - f.ModTime = epoch + f.ModTime = t } } for _, dep := range ch.Dependencies() { - dep.StampModTimes(epoch) + dep.StampModTimes(t) } }