Skip to content

Commit 5cb7e3d

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 5751801 commit 5cb7e3d

7 files changed

Lines changed: 209 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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@ func TestSourceValidation(t *testing.T) {
4141
},
4242
expectErr: true,
4343
},
44+
{
45+
title: "git source checksum must be hex",
46+
src: Source{
47+
Git: &SourceGit{
48+
URL: "https://example.com/repo.git",
49+
Commit: "v1.2.3",
50+
Checksum: "not-a-commit",
51+
},
52+
},
53+
expectErr: true,
54+
},
55+
{
56+
title: "git source checksum accepts hex",
57+
src: Source{
58+
Git: &SourceGit{
59+
URL: "https://example.com/repo.git",
60+
Commit: "v1.2.3",
61+
Checksum: "0123456789abcdef",
62+
},
63+
},
64+
},
4465
{
4566
title: "has multiple source types in docker-image command mount",
4667
expectErr: true,
@@ -701,6 +722,13 @@ func TestSpec_SubstituteBuildArgs(t *testing.T) {
701722
Excludes: []string{"foo/${BAR}"},
702723
Inline: &SourceInline{},
703724
}
725+
spec.Sources["git"] = Source{
726+
Git: &SourceGit{
727+
URL: "https://example.com/foo/${BAR}.git",
728+
Commit: "$FOO",
729+
Checksum: "$BAR",
730+
},
731+
}
704732

705733
spec.Patches = map[string][]PatchSpec{
706734
"src": {
@@ -802,6 +830,9 @@ func TestSpec_SubstituteBuildArgs(t *testing.T) {
802830
assert.Check(t, cmp.Equal(spec.Sources["patch"].Path, "foo/"+bar))
803831
assert.Check(t, cmp.Equal(spec.Sources["patch"].Includes[0], "foo/"+bar))
804832
assert.Check(t, cmp.Equal(spec.Sources["patch"].Excludes[0], "foo/"+bar))
833+
assert.Check(t, cmp.Equal(spec.Sources["git"].Git.URL, "https://example.com/foo/"+bar+".git"))
834+
assert.Check(t, cmp.Equal(spec.Sources["git"].Git.Commit, foo))
835+
assert.Check(t, cmp.Equal(spec.Sources["git"].Git.Checksum, bar))
805836
assert.Check(t, cmp.Equal(spec.Patches["src"][0].Path, foo))
806837

807838
// Base package config

source_git.go

Lines changed: 35 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
}
@@ -99,6 +109,15 @@ func (src *SourceGit) validate(opts fetchOptions) error {
99109
if src.Commit == "" {
100110
errs = append(errs, fmt.Errorf("git source must have a commit"))
101111
}
112+
if src.Checksum != "" {
113+
for _, c := range src.Checksum {
114+
if c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' {
115+
continue
116+
}
117+
errs = append(errs, fmt.Errorf("git source checksum %q must be a hex commit hash", src.Checksum))
118+
break
119+
}
120+
}
102121

103122
if len(errs) > 0 {
104123
err := fmt.Errorf("invalid git source: %w", stderrors.Join(errs...))
@@ -118,6 +137,9 @@ func (src *SourceGit) baseState(opts fetchOptions) llb.State {
118137
if src.KeepGitDir {
119138
gOpts = append(gOpts, llb.KeepGitDir())
120139
}
140+
if src.Checksum != "" {
141+
gOpts = append(gOpts, llb.GitChecksum(src.Checksum))
142+
}
121143
gOpts = append(gOpts, WithConstraints(opts.Constraints...))
122144
gOpts = append(gOpts, &src.Auth)
123145
gOpts = append(gOpts, src._sourceMap.GetRootLocation())
@@ -162,6 +184,12 @@ func (src *SourceGit) processBuildArgs(lex *shell.Lex, args map[string]string, a
162184
if err != nil {
163185
errs = append(errs, err)
164186
}
187+
188+
updated, err = expandArgs(lex, src.Checksum, args, allowArg)
189+
src.Checksum = updated
190+
if err != nil {
191+
errs = append(errs, err)
192+
}
165193
if len(errs) > 0 {
166194
err := fmt.Errorf("failed to process build args for git source: %w", stderrors.Join(errs...))
167195
err = errdefs.WithSource(err, src._sourceMap.GetErrdefsSource())
@@ -185,4 +213,7 @@ func (src *SourceGit) doc(w io.Writer, name string) {
185213
printDocLn(w, "Generated from a git repository:")
186214
printDocLn(w, " Remote:", ref.Remote)
187215
printDocLn(w, " Ref:", src.Commit)
216+
if src.Checksum != "" {
217+
printDocLn(w, " Checksum:", src.Checksum)
218+
}
188219
}

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: 87 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,91 @@ func TestSourceHTTP(t *testing.T) {
453455
})
454456
}
455457

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

website/docs/sources.md

Lines changed: 13 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,24 @@ 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+
Fetching by tag can be useful when tools inspect git metadata during the build.
78+
For example, Go build metadata and Kubernetes-style version tooling can use tag
79+
information from `.git` when `keepGitDir: true` is set, while `checksum` still
80+
ensures the tag resolves to the expected commit.
81+
7182
Git repositories are considered to be "directory" sources.
7283

7384
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)