fix(package): normalize StampModTimes timestamp and add Chart.lock reproducibility test

StampModTimes now normalizes the input time.Time to UTC and truncates
to whole seconds before stamping, ensuring Chart.lock generated field
is deterministic regardless of how the caller constructs the time.Time.

Add chart-with-lock test fixture and TestRunWithSourceDateEpochAndLock
to verify that a chart with a Chart.lock gets its generated field
stamped with the SourceDateEpoch, and that two builds with the same
epoch produce byte-identical output.

Closes #32396

Co-authored-by: atlarix-agent <agent@atlarix.dev>
Signed-off-by: Amariah Kamau <110414493+AmariahAK@users.noreply.github.com>
pull/32403/head
Amariah Kamau 3 months ago
parent 06978bd8c5
commit 15fd16f9d2

@ -17,10 +17,18 @@ limitations under the License.
package action package action
import ( import (
"archive/tar"
"bytes"
"compress/gzip"
"errors" "errors"
"io"
"os" "os"
"path" "path"
"strings"
"testing" "testing"
"time"
"sigs.k8s.io/yaml"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -170,3 +178,69 @@ func TestRun(t *testing.T) {
require.Equal(t, "empty-0.1.0.tgz", filename) require.Equal(t, "empty-0.1.0.tgz", filename)
require.NoError(t, os.Remove(filename)) require.NoError(t, os.Remove(filename))
} }
func TestRunWithSourceDateEpochAndLock(t *testing.T) {
tmp := t.TempDir()
epoch := time.Unix(1609459200, 0).UTC()
// Build twice with the same SourceDateEpoch and assert byte-identical output.
var first []byte
for i := range 2 {
client := NewPackage()
client.Destination = tmp
client.SourceDateEpoch = &epoch
dest, err := client.Run("testdata/charts/chart-with-lock", nil)
require.NoError(t, err)
data, err := os.ReadFile(dest)
require.NoError(t, err)
if i == 0 {
first = data
} else {
require.Equal(t, first, data, "two builds with the same SourceDateEpoch must be byte-identical")
}
require.NoError(t, os.Remove(dest))
}
// Open the archive and inspect Chart.lock content.
gz, err := gzip.NewReader(bytes.NewReader(first))
require.NoError(t, err)
defer gz.Close()
tr := tar.NewReader(gz)
var lockData []byte
var lockHdr *tar.Header
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
require.NoError(t, err)
if strings.HasSuffix(hdr.Name, "Chart.lock") {
lockHdr = hdr
var buf bytes.Buffer
_, err := io.Copy(&buf, tr)
require.NoError(t, err)
lockData = buf.Bytes()
break
}
}
require.NotNil(t, lockHdr, "Chart.lock not found in archive")
require.NotNil(t, lockData)
// Validate the generated field in the YAML is the epoch (not the fixture's original timestamp).
var lock struct {
Generated string `yaml:"generated"`
}
err = yaml.Unmarshal(lockData, &lock)
require.NoError(t, err)
require.Equal(t, "2021-01-01T00:00:00Z", lock.Generated,
"Chart.lock generated field must match SourceDateEpoch")
// Tar header ModTime must also match.
require.True(t, lockHdr.ModTime.Equal(epoch),
"Chart.lock tar header ModTime must match SourceDateEpoch: got %v, want %v",
lockHdr.ModTime, epoch)
}

@ -0,0 +1,6 @@
dependencies:
- name: nginx
repository: https://charts.bitnami.com/bitnami
version: 1.0.0
digest: sha256:abc123def456
generated: "2024-01-01T12:00:00Z"

@ -0,0 +1,4 @@
apiVersion: v2
name: chart-with-lock
description: Test chart with Chart.lock for reproducibility testing
version: 0.1.0

@ -0,0 +1 @@
# This file is intentionally blank

@ -179,32 +179,33 @@ func (ch *Chart) CRDObjects() []CRD {
// StampModTimes sets timestamps on the chart (and dependencies) to epoch. // StampModTimes sets timestamps on the chart (and dependencies) to epoch.
// This is used for reproducible builds via SOURCE_DATE_EPOCH. // This is used for reproducible builds via SOURCE_DATE_EPOCH.
func (ch *Chart) StampModTimes(epoch time.Time) { func (ch *Chart) StampModTimes(t time.Time) {
ch.ModTime = epoch t = t.UTC().Truncate(time.Second)
ch.ModTime = t
if len(ch.Schema) > 0 { if len(ch.Schema) > 0 {
ch.SchemaModTime = epoch ch.SchemaModTime = t
} }
if ch.Lock != nil { if ch.Lock != nil {
ch.Lock.Generated = epoch ch.Lock.Generated = t
} }
for _, f := range ch.Raw { for _, f := range ch.Raw {
if f != nil { if f != nil {
f.ModTime = epoch f.ModTime = t
} }
} }
for _, f := range ch.Templates { for _, f := range ch.Templates {
if f != nil { if f != nil {
f.ModTime = epoch f.ModTime = t
} }
} }
for _, f := range ch.Files { for _, f := range ch.Files {
if f != nil { if f != nil {
f.ModTime = epoch f.ModTime = t
} }
} }
for _, dep := range ch.Dependencies() { for _, dep := range ch.Dependencies() {
dep.StampModTimes(epoch) dep.StampModTimes(t)
} }
} }

Loading…
Cancel
Save