Skip to content

Commit 9210be9

Browse files
committed
sources: verify git refs with checksum
Add an optional git source checksum that is passed to BuildKit so tags and branches can be fetched by ref while ensuring they resolve to the expected commit. This preserves .git tag metadata when keepGitDir is enabled, which lets tools derive version information from tags without giving up source pinning. If a tag or branch moves away from the expected commit, checksum verification fails the build. Signed-off-by: Brian Goff <cpuguy83@gmail.com>
1 parent e7acef6 commit 9210be9

7 files changed

Lines changed: 247 additions & 11 deletions

File tree

docs/spec.schema.json

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,22 +1820,33 @@
18201820
],
18211821
"properties": {
18221822
"auth": {
1823-
"$ref": "#/$defs/GitAuth"
1823+
"$ref": "#/$defs/GitAuth",
1824+
"description": "Auth can be used to add instructions on how to authenticate with the git repository."
1825+
},
1826+
"checksum": {
1827+
"type": [
1828+
"string",
1829+
"null"
1830+
],
1831+
"description": "Checksum is the expected commit hash for the resolved ref.\nIt is useful when \"Commit\" refers to a mutable ref, such as a tag."
18241832
},
18251833
"commit": {
18261834
"type": [
18271835
"string"
1828-
]
1836+
],
1837+
"description": "Commit is the ref, which may be a commit, a tag, or even a full ref.\nNOTE: When using a commit ref, tag info is *not* fetched."
18291838
},
18301839
"keepGitDir": {
18311840
"type": [
18321841
"boolean"
1833-
]
1842+
],
1843+
"description": "KeepGitDir includes the .git directory in the source.\nNOTE: Not all git metadata may be available. The available metadata depends\non the ref specified by \"Commit\"."
18341844
},
18351845
"url": {
18361846
"type": [
18371847
"string"
1838-
]
1848+
],
1849+
"description": "URL is the URL of the git repository."
18391850
}
18401851
},
18411852
"additionalProperties": {

load_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ func TestSourceValidation(t *testing.T) {
4141
},
4242
expectErr: true,
4343
},
44+
{
45+
title: "git source checksum accepts hex",
46+
src: Source{
47+
Git: &SourceGit{
48+
URL: "https://example.com/repo.git",
49+
Commit: "v1.2.3",
50+
Checksum: "0123456789abcdef",
51+
},
52+
},
53+
},
4454
{
4555
title: "has multiple source types in docker-image command mount",
4656
expectErr: true,
@@ -666,6 +676,7 @@ func TestSpec_SubstituteBuildArgs(t *testing.T) {
666676
const (
667677
foo = "foo"
668678
bar = "bar"
679+
checksum = "baddecaf"
669680
argWithDefault = "some default value"
670681
plainOleValue = "some plain old value"
671682
)
@@ -701,6 +712,13 @@ func TestSpec_SubstituteBuildArgs(t *testing.T) {
701712
Excludes: []string{"foo/${BAR}"},
702713
Inline: &SourceInline{},
703714
}
715+
spec.Sources["git"] = Source{
716+
Git: &SourceGit{
717+
URL: "https://example.com/foo/${BAR}.git",
718+
Commit: "$FOO",
719+
Checksum: "$CHECKSUM",
720+
},
721+
}
704722

705723
spec.Patches = map[string][]PatchSpec{
706724
"src": {
@@ -795,13 +813,18 @@ func TestSpec_SubstituteBuildArgs(t *testing.T) {
795813
env["BAR"] = bar
796814

797815
spec.Args["BAR"] = ""
816+
spec.Args["CHECKSUM"] = ""
798817
spec.Args["VAR_WITH_DEFAULT"] = argWithDefault
818+
env["CHECKSUM"] = checksum
799819

800820
assert.NilError(t, spec.SubstituteArgs(env))
801821

802822
assert.Check(t, cmp.Equal(spec.Sources["patch"].Path, "foo/"+bar))
803823
assert.Check(t, cmp.Equal(spec.Sources["patch"].Includes[0], "foo/"+bar))
804824
assert.Check(t, cmp.Equal(spec.Sources["patch"].Excludes[0], "foo/"+bar))
825+
assert.Check(t, cmp.Equal(spec.Sources["git"].Git.URL, "https://example.com/foo/"+bar+".git"))
826+
assert.Check(t, cmp.Equal(spec.Sources["git"].Git.Commit, foo))
827+
assert.Check(t, cmp.Equal(spec.Sources["git"].Git.Checksum, checksum))
805828
assert.Check(t, cmp.Equal(spec.Patches["src"][0].Path, foo))
806829

807830
// Base package config
@@ -1193,6 +1216,26 @@ targets:
11931216
assert.Check(t, cmp.Equal(target.PackageConfig.Signer.Args["FOO"], "test"))
11941217
})
11951218

1219+
t.Run("git checksum build arg loaded from yaml", func(t *testing.T) {
1220+
dt := []byte(`
1221+
args:
1222+
COMMIT:
1223+
sources:
1224+
test:
1225+
git:
1226+
url: https://example.com/repo.git
1227+
commit: v1.2.3
1228+
checksum: ${COMMIT}
1229+
`)
1230+
1231+
spec, err := LoadSpec(dt)
1232+
assert.NilError(t, err)
1233+
1234+
err = spec.SubstituteArgs(map[string]string{"COMMIT": "0123456789abcdef"})
1235+
assert.NilError(t, err)
1236+
assert.Check(t, cmp.Equal(spec.Sources["test"].Git.Checksum, "0123456789abcdef"))
1237+
})
1238+
11961239
t.Run("default value", func(t *testing.T) {
11971240
dt := []byte(`
11981241
args:

source_git.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,20 @@ import (
1313
)
1414

1515
type SourceGit struct {
16-
URL string `yaml:"url" json:"url"`
17-
Commit string `yaml:"commit" json:"commit"`
18-
KeepGitDir bool `yaml:"keepGitDir,omitempty" json:"keepGitDir,omitempty"`
19-
Auth GitAuth `yaml:"auth,omitempty" json:"auth,omitempty"`
16+
// URL is the URL of the git repository.
17+
URL string `yaml:"url" json:"url"`
18+
// Commit is the ref, which may be a commit, a tag, or even a full ref.
19+
// NOTE: When using a commit ref, tag info is *not* fetched.
20+
Commit string `yaml:"commit" json:"commit"`
21+
// Checksum is the expected commit hash for the resolved ref.
22+
// It is useful when "Commit" refers to a mutable ref, such as a tag.
23+
Checksum string `yaml:"checksum,omitempty" json:"checksum,omitempty"`
24+
// KeepGitDir includes the .git directory in the source.
25+
// NOTE: Not all git metadata may be available. The available metadata depends
26+
// on the ref specified by "Commit".
27+
KeepGitDir bool `yaml:"keepGitDir,omitempty" json:"keepGitDir,omitempty"`
28+
// Auth can be used to add instructions on how to authenticate with the git repository.
29+
Auth GitAuth `yaml:"auth,omitempty" json:"auth,omitempty"`
2030

2131
_sourceMap *sourceMap `yaml:"-" json:"-"`
2232
}
@@ -118,6 +128,9 @@ func (src *SourceGit) baseState(opts fetchOptions) llb.State {
118128
if src.KeepGitDir {
119129
gOpts = append(gOpts, llb.KeepGitDir())
120130
}
131+
if src.Checksum != "" {
132+
gOpts = append(gOpts, llb.GitChecksum(src.Checksum))
133+
}
121134
gOpts = append(gOpts, WithConstraints(opts.Constraints...))
122135
gOpts = append(gOpts, &src.Auth)
123136
gOpts = append(gOpts, src._sourceMap.GetRootLocation())
@@ -162,6 +175,12 @@ func (src *SourceGit) processBuildArgs(lex *shell.Lex, args map[string]string, a
162175
if err != nil {
163176
errs = append(errs, err)
164177
}
178+
179+
updated, err = expandArgs(lex, src.Checksum, args, allowArg)
180+
src.Checksum = updated
181+
if err != nil {
182+
errs = append(errs, err)
183+
}
165184
if len(errs) > 0 {
166185
err := fmt.Errorf("failed to process build args for git source: %w", stderrors.Join(errs...))
167186
err = errdefs.WithSource(err, src._sourceMap.GetErrdefsSource())
@@ -185,4 +204,7 @@ func (src *SourceGit) doc(w io.Writer, name string) {
185204
printDocLn(w, "Generated from a git repository:")
186205
printDocLn(w, " Remote:", ref.Remote)
187206
printDocLn(w, " Ref:", src.Commit)
207+
if src.Checksum != "" {
208+
printDocLn(w, " Checksum:", src.Checksum)
209+
}
188210
}

source_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,20 @@ func TestSourceGitHTTP(t *testing.T) {
175175
checkGitOp(t, ops, &src)
176176
})
177177

178+
t.Run("with checksum and git dir", func(t *testing.T) {
179+
src := Source{
180+
Git: &SourceGit{
181+
URL: "https://localhost/test.git",
182+
Commit: "v1.2.3",
183+
Checksum: "0123456789abcdef",
184+
KeepGitDir: true,
185+
},
186+
}
187+
188+
ops := getSourceOp(ctx, t, src)
189+
checkGitOp(t, ops, &src)
190+
})
191+
178192
t.Run("gomod auth", func(t *testing.T) {
179193
const (
180194
numSecrets = 2
@@ -1032,6 +1046,18 @@ func checkGitOp(t *testing.T, ops []*pb.Op, src *Source) {
10321046
t.Errorf("expected git.fullurl %q, got %q", src.Git.URL, op.Attrs["git.fullurl"])
10331047
}
10341048

1049+
if src.Git.Checksum != "" {
1050+
assert.Check(t, cmp.Equal(op.Attrs[pb.AttrGitChecksum], src.Git.Checksum), op.Attrs)
1051+
} else {
1052+
assert.Check(t, cmp.Equal(op.Attrs[pb.AttrGitChecksum], ""), op.Attrs)
1053+
}
1054+
1055+
if src.Git.KeepGitDir {
1056+
assert.Check(t, cmp.Equal(op.Attrs[pb.AttrKeepGitDir], "true"), op.Attrs)
1057+
} else {
1058+
assert.Check(t, cmp.Equal(op.Attrs[pb.AttrKeepGitDir], ""), op.Attrs)
1059+
}
1060+
10351061
const (
10361062
defaultAuthHeader = "GIT_AUTH_HEADER"
10371063
defaultAuthToken = "GIT_AUTH_TOKEN"

test/source_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313
"github.com/opencontainers/go-digest"
1414
"github.com/project-dalec/dalec"
1515
"github.com/project-dalec/dalec/frontend/pkg/bkfs"
16+
gitservices "github.com/project-dalec/dalec/test/git_services"
17+
"github.com/project-dalec/dalec/test/testenv"
1618
"gotest.tools/v3/assert"
1719
)
1820

@@ -453,6 +455,123 @@ func TestSourceHTTP(t *testing.T) {
453455
})
454456
}
455457

458+
func TestSourceGitChecksumPreservesTagMetadata(t *testing.T) {
459+
t.Parallel()
460+
461+
parentCtx := startTestSpan(baseCtx, t)
462+
463+
const (
464+
secretName = "super-secret"
465+
sourceName = "repo"
466+
tagName = "v1.2.3"
467+
)
468+
469+
runGitServerTest := func(ctx context.Context, t *testing.T, f func(context.Context, gwclient.Client, llb.State, string, func(string) *dalec.Spec)) {
470+
t.Helper()
471+
472+
testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) {
473+
t.Helper()
474+
475+
attr := gitservices.Attributes{
476+
ServerRoot: "/",
477+
PrivateRepoPath: "username/private",
478+
HTTPServerPath: "/usr/local/bin/git_http_server",
479+
HTTPPort: "8080",
480+
}
481+
482+
testState := gitservices.NewTestState(t, gwc, &attr)
483+
worker := initWorker(gwc)
484+
repo := llb.Scratch().File(llb.Mkfile("foo", 0o644, []byte("bar\n")))
485+
repo = worker.Dir(attr.PrivateRepoAbsPath()).Run(dalec.ShArgs(`
486+
set -eux
487+
export GIT_CONFIG_NOGLOBAL=true
488+
git init
489+
git config user.name foo
490+
git config user.email foo@bar.com
491+
git add -A
492+
git commit -m commit --no-gpg-sign
493+
git tag -a `+tagName+` -m `+tagName+`
494+
`)).AddMount(attr.RepoAbsDir(), repo)
495+
496+
commitSt := worker.Dir(attr.PrivateRepoAbsPath()).Run(dalec.ShArgs(`
497+
set -eu
498+
git rev-parse HEAD > /out/commit
499+
`), llb.AddMount(attr.RepoAbsDir(), repo, llb.Readonly)).AddMount("/out", llb.Scratch())
500+
commitDef, err := commitSt.Marshal(ctx)
501+
assert.NilError(t, err)
502+
503+
commitRes, err := gwc.Solve(ctx, gwclient.SolveRequest{Definition: commitDef.ToPB(), Evaluate: true})
504+
assert.NilError(t, err)
505+
commit := strings.TrimSpace(string(readFile(ctx, t, "commit", commitRes)))
506+
507+
gitHost := worker.With(hostedRepo(repo, attr.RepoAbsDir()))
508+
httpServer := testState.StartHTTPGitServer(ctx, gitHost)
509+
510+
newSpec := func(checksum string) *dalec.Spec {
511+
return &dalec.Spec{
512+
Sources: map[string]dalec.Source{
513+
sourceName: {
514+
Git: &dalec.SourceGit{
515+
URL: "http://" + httpServer.IP + ":" + httpServer.Port + "/" + attr.PrivateRepoPath,
516+
Commit: tagName,
517+
Checksum: checksum,
518+
KeepGitDir: true,
519+
Auth: dalec.GitAuth{
520+
Token: secretName,
521+
},
522+
},
523+
},
524+
},
525+
}
526+
}
527+
528+
f(ctx, gwc, worker, commit, newSpec)
529+
}, testenv.WithSecrets(secretName, "password"))
530+
}
531+
532+
t.Run("preserves tag metadata", func(t *testing.T) {
533+
t.Parallel()
534+
ctx := startTestSpan(parentCtx, t)
535+
536+
runGitServerTest(ctx, t, func(ctx context.Context, gwc gwclient.Client, worker llb.State, commit string, newSpec func(string) *dalec.Spec) {
537+
req := newSolveRequest(withBuildTarget("debug/sources"), withSpec(ctx, t, newSpec(commit)))
538+
sourceRes := solveT(ctx, t, gwc, req)
539+
verifySt := worker.Run(dalec.ShArgs(`
540+
set -eu
541+
git -C /src/`+sourceName+` tag --points-at HEAD > /out/tag
542+
git -C /src/`+sourceName+` rev-parse HEAD > /out/head
543+
git -C /src/`+sourceName+` rev-parse `+tagName+`^{} > /out/tag-commit
544+
git -C /src/`+sourceName+` cat-file -t `+tagName+` > /out/tag-type
545+
`), llb.AddMount("/src", resultToState(t, sourceRes), llb.Readonly)).AddMount("/out", llb.Scratch())
546+
verifyDef, err := verifySt.Marshal(ctx)
547+
assert.NilError(t, err)
548+
549+
verifyRes, err := gwc.Solve(ctx, gwclient.SolveRequest{Definition: verifyDef.ToPB(), Evaluate: true})
550+
assert.NilError(t, err)
551+
552+
checkFile(ctx, t, "tag", verifyRes, []byte(tagName+"\n"))
553+
checkFile(ctx, t, "head", verifyRes, []byte(commit+"\n"))
554+
checkFile(ctx, t, "tag-commit", verifyRes, []byte(commit+"\n"))
555+
checkFile(ctx, t, "tag-type", verifyRes, []byte("tag\n"))
556+
})
557+
})
558+
559+
t.Run("rejects checksum mismatch", func(t *testing.T) {
560+
t.Parallel()
561+
ctx := startTestSpan(parentCtx, t)
562+
563+
runGitServerTest(ctx, t, func(ctx context.Context, gwc gwclient.Client, worker llb.State, commit string, newSpec func(string) *dalec.Spec) {
564+
_, err := gwc.Solve(ctx, newSolveRequest(withBuildTarget("debug/sources"), withSpec(ctx, t, newSpec("0000000000000000000000000000000000000000"))))
565+
if err == nil {
566+
t.Fatal("expected git checksum mismatch, but received none")
567+
}
568+
if !strings.Contains(err.Error(), "expected checksum to match") {
569+
t.Fatalf("expected git checksum mismatch, got: %v", err)
570+
}
571+
})
572+
})
573+
}
574+
456575
// Create a very simple fake module with a limited dependency tree just to
457576
// keep the test as fast/reliable as possible.
458577
const gomodFixtureMain = `package main

website/docs/sources.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ sources:
4343
4444
### Git
4545
46-
Git sources fetch a git repository at a specific commit.
46+
Git sources fetch a git repository at a specific commit, branch, or tag ref.
4747
You can use either an SSH style git URL or an HTTPS style git URL.
4848
4949
For SSH style git URLs, if the client (such as the docker CLI) has provided
@@ -61,13 +61,27 @@ sources:
6161
git:
6262
# This uses an HTTPS style git URL.
6363
url: https://github.com/myOrg/myRepo.git
64-
commit: 1234567890abcdef
64+
commit: v1.2.3
65+
checksum: 1234567890abcdef # [Optional] Verify the ref resolves to this commit.
6566
keepGitDir: true # [Optional] Keep the .git directory when fetching the git source. Default: false
6667
```
6768
6869
By default, Dalec will discard the `.git` directory when fetching a git source.
6970
You can override this behavior by setting `keepGitDir: true` in the git configuration.
7071

72+
When `commit` is a branch or tag, `checksum` can be used to verify that the ref
73+
resolves to the expected commit. This lets you fetch a tag while still pinning
74+
the content to a known commit. BuildKit accepts a full or short hex commit hash
75+
for `checksum`.
76+
77+
`checksum` requires BuildKit v0.22.0 or later. When using Docker's embedded
78+
BuildKit backend, use Docker Engine v28.2.0 or later.
79+
80+
Fetching by tag can be useful when tools inspect git metadata during the build.
81+
For example, Go build metadata and Kubernetes-style version tooling can use tag
82+
information from `.git` when `keepGitDir: true` is set, while `checksum` still
83+
ensures the tag resolves to the expected commit.
84+
7185
Git repositories are considered to be "directory" sources.
7286

7387
Authentication will be handled using some default secret names which are fetched

website/docs/spec.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ sources:
180180
foo:
181181
git:
182182
url: https://github.com/foo/bar.git
183-
commit: ${COMMIT}
183+
commit: ${TAG}
184+
checksum: ${COMMIT}
184185
keepGitDir: true
185186
generate:
186187
- gomod: {}

0 commit comments

Comments
 (0)