Skip to content

Commit dc560c9

Browse files
committed
oci: add SOURCE_DATE_EPOCH support for reproducible builds
Add support for SOURCE_DATE_EPOCH environment variable to enable reproducible builds by eliminating timestamp-based non-determinism. The implementation follows the tar --clamp-mtime behavior, only modifying timestamps newer than SOURCE_DATE_EPOCH while preserving historical timestamps. This affects new, repack, insert, and config commands. Signed-off-by: Danish Prakash <contact@danishpraka.sh>
1 parent ce11bc0 commit dc560c9

13 files changed

Lines changed: 369 additions & 7 deletions

File tree

cmd/umoci/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,17 @@ func config(ctx *cli.Context) (Err error) {
293293
}
294294
}
295295

296+
sourceDateEpoch, err := parseSourceDateEpoch()
297+
if err != nil {
298+
return err
299+
}
300+
296301
var history *ispec.History
297302
if !ctx.Bool("no-history") {
298303
created := time.Now()
304+
if !sourceDateEpoch.IsZero() {
305+
created = sourceDateEpoch
306+
}
299307
history = &ispec.History{
300308
Author: g.Author(),
301309
Comment: "",

cmd/umoci/insert.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,26 @@ func insert(ctx *cli.Context) (Err error) {
165165
return err
166166
}
167167

168+
sourceDateEpoch, err := parseSourceDateEpoch()
169+
if err != nil {
170+
return err
171+
}
172+
168173
packOptions := layer.RepackOptions{
169174
OnDiskFormat: layer.DirRootfs{
170175
MapOptions: meta.MapOptions,
171176
},
177+
SourceDateEpoch: sourceDateEpoch,
172178
}
173179
reader := layer.GenerateInsertLayer(sourcePath, targetPath, ctx.IsSet("opaque"), &packOptions)
174180
defer funchelpers.VerifyClose(&Err, reader)
175181

176182
var history *ispec.History
177183
if !ctx.Bool("no-history") {
178184
created := time.Now()
185+
if !sourceDateEpoch.IsZero() {
186+
created = sourceDateEpoch
187+
}
179188
history = &ispec.History{
180189
Comment: "",
181190
Created: &created,
@@ -189,6 +198,8 @@ func insert(ctx *cli.Context) (Err error) {
189198
if ctx.IsSet("history.comment") {
190199
history.Comment = ctx.String("history.comment")
191200
}
201+
202+
// cli flag takes precedence over SOURCE_DATE_EPOCH, if set
192203
if ctx.IsSet("history.created") {
193204
created, err := time.Parse(igen.ISO8601, ctx.String("history.created"))
194205
if err != nil {

cmd/umoci/new.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ func newImage(ctx *cli.Context) (Err error) {
6060
imagePath := mustFetchMeta[string](ctx, "--image-path")
6161
tagName := mustFetchMeta[string](ctx, "--image-tag")
6262

63+
sourceDateEpoch, err := parseSourceDateEpoch()
64+
if err != nil {
65+
return err
66+
}
67+
6368
// Get a reference to the CAS.
6469
engine, err := dir.Open(imagePath)
6570
if err != nil {
@@ -68,5 +73,5 @@ func newImage(ctx *cli.Context) (Err error) {
6873
engineExt := casext.NewEngine(engine)
6974
defer funchelpers.VerifyClose(&Err, engine)
7075

71-
return umoci.NewImage(engineExt, tagName)
76+
return umoci.NewImage(engineExt, tagName, sourceDateEpoch)
7277
}

cmd/umoci/raw-add-layer.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,17 @@ func rawAddLayer(ctx *cli.Context) (Err error) {
134134
return fmt.Errorf("get image metadata: %w", err)
135135
}
136136

137+
sourceDateEpoch, err := parseSourceDateEpoch()
138+
if err != nil {
139+
return err
140+
}
141+
137142
var history *ispec.History
138143
if !ctx.Bool("no-history") {
139144
created := time.Now()
145+
if !sourceDateEpoch.IsZero() {
146+
created = sourceDateEpoch
147+
}
140148
history = &ispec.History{
141149
Author: imageMeta.Author,
142150
Comment: "",

cmd/umoci/repack.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,17 @@ func repack(ctx *cli.Context) (Err error) {
152152
return fmt.Errorf("get image metadata: %w", err)
153153
}
154154

155+
sourceDateEpoch, err := parseSourceDateEpoch()
156+
if err != nil {
157+
return err
158+
}
159+
155160
var history *ispec.History
156161
if !ctx.Bool("no-history") {
157162
created := time.Now()
163+
if !sourceDateEpoch.IsZero() {
164+
created = sourceDateEpoch
165+
}
158166
history = &ispec.History{
159167
Author: imageMeta.Author,
160168
Comment: "",
@@ -185,5 +193,5 @@ func repack(ctx *cli.Context) (Err error) {
185193
mtreefilter.MaskFilter(maskedPaths),
186194
}
187195

188-
return umoci.Repack(engineExt, tagName, bundlePath, meta, history, filters, ctx.Bool("refresh-bundle"), mutator, compressAlgo)
196+
return umoci.Repack(engineExt, tagName, bundlePath, meta, history, filters, ctx.Bool("refresh-bundle"), mutator, compressAlgo, sourceDateEpoch)
189197
}

cmd/umoci/utils.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
/*
3+
* umoci: Umoci Modifies Open Containers' Images
4+
* Copyright (C) 2016-2025 SUSE LLC
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
// Package main contains utility functions for umoci commands.
20+
package main
21+
22+
import (
23+
"fmt"
24+
"os"
25+
"strconv"
26+
"time"
27+
)
28+
29+
// parseSourceDateEpoch parses the SOURCE_DATE_EPOCH environment variable
30+
// and returns the corresponding time.Time, or zero time if not set.
31+
func parseSourceDateEpoch() (time.Time, error) {
32+
sourceDateEpochStr := os.Getenv("SOURCE_DATE_EPOCH")
33+
if sourceDateEpochStr == "" {
34+
return time.Time{}, nil
35+
}
36+
37+
timestamp, err := strconv.ParseInt(sourceDateEpochStr, 10, 64)
38+
if err != nil {
39+
return time.Time{}, fmt.Errorf("parsing SOURCE_DATE_EPOCH %q: %w", sourceDateEpochStr, err)
40+
}
41+
42+
return time.Unix(timestamp, 0).UTC(), nil
43+
}

cmd/umoci/utils_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
/*
3+
* umoci: Umoci Modifies Open Containers' Images
4+
* Copyright (C) 2016-2025 SUSE LLC
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package main
20+
21+
import (
22+
"os"
23+
"testing"
24+
"time"
25+
26+
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
28+
)
29+
30+
func TestParseSourceDateEpoch(t *testing.T) {
31+
t.Run("EmptyEnvironmentVariable", func(t *testing.T) {
32+
require.NoError(t, os.Unsetenv("SOURCE_DATE_EPOCH"))
33+
34+
result, err := parseSourceDateEpoch()
35+
36+
require.NoError(t, err)
37+
assert.True(t, result.IsZero())
38+
})
39+
40+
t.Run("ValidTimestamp", func(t *testing.T) {
41+
t.Setenv("SOURCE_DATE_EPOCH", "1234567890")
42+
43+
result, err := parseSourceDateEpoch()
44+
45+
require.NoError(t, err)
46+
assert.False(t, result.IsZero())
47+
assert.Equal(t, int64(1234567890), result.Unix())
48+
assert.Equal(t, time.UTC, result.Location())
49+
})
50+
51+
t.Run("InvalidFormat", func(t *testing.T) {
52+
t.Setenv("SOURCE_DATE_EPOCH", "not-a-number")
53+
54+
result, err := parseSourceDateEpoch()
55+
56+
require.Error(t, err)
57+
assert.True(t, result.IsZero())
58+
assert.Contains(t, err.Error(), "parsing SOURCE_DATE_EPOCH")
59+
})
60+
}

new.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import (
3333
)
3434

3535
// NewImage creates a new empty image (tag) in the existing layout.
36-
func NewImage(engineExt casext.Engine, tagName string) error {
36+
func NewImage(engineExt casext.Engine, tagName string, sourceDateEpoch time.Time) error {
3737
// Create a new manifest.
3838
log.WithFields(log.Fields{
3939
"tag": tagName,
@@ -42,6 +42,9 @@ func NewImage(engineExt casext.Engine, tagName string) error {
4242
// Create a new image config.
4343
g := igen.New()
4444
createTime := time.Now()
45+
if !sourceDateEpoch.IsZero() {
46+
createTime = sourceDateEpoch
47+
}
4548

4649
// Set all of the defaults we need.
4750
g.SetCreated(createTime)

oci/layer/tar_generate.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"os"
2727
"path/filepath"
2828
"strings"
29+
"time"
2930

3031
"github.com/apex/log"
3132
"golang.org/x/sys/unix"
@@ -52,6 +53,10 @@ type tarGenerator struct {
5253
// fsEval is an fseval.FsEval used for extraction.
5354
fsEval fseval.FsEval
5455

56+
// sourceDateEpoch, if set, clamps file timestamps in the generated tar
57+
// to not be newer than this time (follows tar --clamp-mtime behavior).
58+
sourceDateEpoch time.Time
59+
5560
// XXX: Should we add a safety check to make sure we don't generate two of
5661
// the same path in a tar archive? This is not permitted by the spec.
5762
}
@@ -67,10 +72,11 @@ func newTarGenerator(w io.Writer, opt *RepackOptions) *tarGenerator {
6772
}
6873

6974
return &tarGenerator{
70-
tw: tar.NewWriter(w),
71-
onDiskFmt: opt.OnDiskFormat,
72-
inodes: map[uint64]string{},
73-
fsEval: fsEval,
75+
tw: tar.NewWriter(w),
76+
onDiskFmt: opt.OnDiskFormat,
77+
inodes: map[uint64]string{},
78+
fsEval: fsEval,
79+
sourceDateEpoch: opt.SourceDateEpoch,
7480
}
7581
}
7682

@@ -126,6 +132,22 @@ func (tg *tarGenerator) AddFile(name, path string) (Err error) {
126132
if err != nil {
127133
return fmt.Errorf("convert fi to hdr: %w", err)
128134
}
135+
136+
// Apply SOURCE_DATE_EPOCH timestamp clamping if set.
137+
// Only clamp timestamps that are newer than SOURCE_DATE_EPOCH
138+
// effectively following tar --clamp-mtime behavior.
139+
if !tg.sourceDateEpoch.IsZero() {
140+
sourceDateEpoch := tg.sourceDateEpoch
141+
if !hdr.ModTime.IsZero() && hdr.ModTime.After(sourceDateEpoch) {
142+
hdr.ModTime = sourceDateEpoch
143+
}
144+
if !hdr.AccessTime.IsZero() && hdr.AccessTime.After(sourceDateEpoch) {
145+
hdr.AccessTime = sourceDateEpoch
146+
}
147+
if !hdr.ChangeTime.IsZero() && hdr.ChangeTime.After(sourceDateEpoch) {
148+
hdr.ChangeTime = sourceDateEpoch
149+
}
150+
}
129151
hdr.Xattrs = map[string]string{} //nolint:staticcheck // SA1019: Xattrs is deprecated but PAXRecords is more annoying
130152
// Usually incorrect for containers and was added in Go 1.10 causing
131153
// changes to our output on a compiler bump...

0 commit comments

Comments
 (0)