Skip to content

Commit 3ebfb2a

Browse files
committed
[provenance] More sensitive GIt status use
1 parent b7d2b54 commit 3ebfb2a

File tree

5 files changed

+266
-53
lines changed

5 files changed

+266
-53
lines changed

cmd/provenance-export.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,18 @@ var provenanceExportCmd = &cobra.Command{
6060
}
6161
defer f.Close()
6262
err = provutil.DecodeBundle(f, export)
63+
if err != nil {
64+
log.WithError(err).Fatal("cannot extract attestation bundle")
65+
}
6366
} else {
6467
err = leeway.AccessAttestationBundleInCachedArchive(pkgFN, func(bundle io.Reader) error {
6568
return provutil.DecodeBundle(bundle, export)
6669
})
70+
if err != nil {
71+
log.WithError(err).Fatal("cannot extract attestation bundle")
72+
}
6773
}
68-
if err != nil {
69-
log.WithError(err).Fatal("cannot extract attestation bundle")
70-
}
74+
7175
},
7276
}
7377

pkg/leeway/gitinfo.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package leeway
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"strings"
8+
9+
log "github.com/sirupsen/logrus"
10+
"golang.org/x/xerrors"
11+
)
12+
13+
type GitInfo struct {
14+
WorkingCopyLoc string
15+
Commit string
16+
Origin string
17+
18+
dirty bool
19+
dirtyFiles map[string]struct{}
20+
}
21+
22+
// GetGitInfo returns the git status required during a leeway build
23+
func GetGitInfo(loc string) (*GitInfo, error) {
24+
gitfc := filepath.Join(loc, ".git")
25+
stat, err := os.Stat(gitfc)
26+
if err != nil || !stat.IsDir() {
27+
return nil, nil
28+
}
29+
30+
cmd := exec.Command("git", "rev-parse", "HEAD")
31+
cmd.Dir = loc
32+
out, err := cmd.CombinedOutput()
33+
if err != nil {
34+
return nil, err
35+
}
36+
res := GitInfo{
37+
WorkingCopyLoc: loc,
38+
Commit: strings.TrimSpace(string(out)),
39+
}
40+
41+
cmd = exec.Command("git", "config", "--get", "remote.origin.url")
42+
cmd.Dir = loc
43+
out, err = cmd.CombinedOutput()
44+
if err != nil && len(out) > 0 {
45+
return nil, err
46+
}
47+
res.Origin = strings.TrimSpace(string(out))
48+
49+
cmd = exec.Command("git", "status", "--porcelain")
50+
cmd.Dir = loc
51+
out, err = cmd.CombinedOutput()
52+
if serr, ok := err.(*exec.ExitError); ok && serr.ExitCode() != 128 {
53+
// git status --short seems to exit with 128 all the time - that's ok, but we need to account for that.
54+
log.WithField("exitCode", serr.ExitCode()).Debug("git status --porcelain exited with failed exit code. Working copy is dirty.")
55+
res.dirty = true
56+
} else if _, ok := err.(*exec.ExitError); !ok && err != nil {
57+
return nil, err
58+
} else if len(strings.TrimSpace(string(out))) != 0 {
59+
log.WithField("out", string(out)).Debug("`git status --porcelain` produced output. Working copy is dirty.")
60+
61+
res.dirty = true
62+
res.dirtyFiles, err = parseGitStatus(out)
63+
if err != nil {
64+
log.WithError(err).Warn("cannot parse git status: assuming all files are dirty")
65+
}
66+
}
67+
68+
return &res, nil
69+
}
70+
71+
// parseGitStatus parses the output of "git status --porcelain"
72+
func parseGitStatus(out []byte) (files map[string]struct{}, err error) {
73+
in := strings.TrimSpace(string(out))
74+
if len(in) == 0 {
75+
// no files - nothing's dirty
76+
return nil, nil
77+
}
78+
79+
lines := strings.Split(in, "\n")
80+
files = make(map[string]struct{}, len(lines))
81+
for _, l := range lines {
82+
segs := strings.Fields(l)
83+
if len(segs) == 0 {
84+
continue
85+
}
86+
if len(segs) != 2 {
87+
return nil, xerrors.Errorf("cannot parse git status \"%s\": expected two segments, got %d", l, len(segs))
88+
}
89+
files[segs[1]] = struct{}{}
90+
}
91+
return
92+
}
93+
94+
// DirtyFiles returns true if a single file of the file list
95+
// is dirty.
96+
func (gi *GitInfo) DirtyFiles(files []string) bool {
97+
if !gi.dirty {
98+
// nothing's dirty
99+
log.WithField("workingCopy", gi.WorkingCopyLoc).Debug("building from a clean working copy")
100+
return false
101+
}
102+
if len(gi.dirtyFiles) == 0 {
103+
// we don't have any record of dirty files, just that the
104+
// working copy is dirty. Hence, we assume all files are dirty.
105+
log.WithField("workingCopy", gi.WorkingCopyLoc).Debug("no records of dirty files - assuming dirty Git working copy")
106+
return true
107+
}
108+
for _, f := range files {
109+
if !strings.HasPrefix(f, gi.WorkingCopyLoc) {
110+
// We don't know anything about this file, but the caller
111+
// might make important decisions on the dirty-state of
112+
// the files. For good measure we assume the file is dirty.
113+
log.WithField("workingCopy", gi.WorkingCopyLoc).WithField("fn", f).Debug("no records of this file - assuming it's dirty")
114+
return true
115+
}
116+
117+
fn := strings.TrimPrefix(f, gi.WorkingCopyLoc)
118+
fn = strings.TrimPrefix(fn, "/")
119+
_, dirty := gi.dirtyFiles[fn]
120+
if dirty {
121+
log.WithField("workingCopy", gi.WorkingCopyLoc).WithField("fn", f).Debug("found dirty source file")
122+
return true
123+
}
124+
}
125+
return false
126+
}

pkg/leeway/gitinfo_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package leeway
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/go-cmp/cmp"
7+
)
8+
9+
func TestParseGitStatus(t *testing.T) {
10+
type Expectation struct {
11+
Files map[string]struct{}
12+
Err string
13+
}
14+
tests := []struct {
15+
Name string
16+
In string
17+
Expectation Expectation
18+
}{
19+
{
20+
Name: "empty input",
21+
Expectation: Expectation{},
22+
},
23+
{
24+
Name: "garbage",
25+
In: "hello world, this is garbage\nand some more",
26+
Expectation: Expectation{
27+
Err: `cannot parse git status "hello world, this is garbage": expected two segments, got 5`,
28+
},
29+
},
30+
{
31+
Name: "valid input",
32+
In: " M foobar\n M this/is/a/file",
33+
Expectation: Expectation{
34+
Files: map[string]struct{}{
35+
"foobar": {},
36+
"this/is/a/file": {},
37+
},
38+
},
39+
},
40+
}
41+
for _, test := range tests {
42+
t.Run(test.Name, func(t *testing.T) {
43+
files, err := parseGitStatus([]byte(test.In))
44+
var act Expectation
45+
act.Files = files
46+
if err != nil {
47+
act.Err = err.Error()
48+
}
49+
if diff := cmp.Diff(test.Expectation, act); diff != "" {
50+
t.Errorf("ParseGitStatus() mismatch (-want +got):\n%s", diff)
51+
}
52+
})
53+
}
54+
}
55+
56+
func TestGitInfoDirtyFiles(t *testing.T) {
57+
tests := []struct {
58+
Name string
59+
In *GitInfo
60+
Files []string
61+
Expectation bool
62+
}{
63+
{
64+
Name: "empty input",
65+
In: &GitInfo{},
66+
Expectation: false,
67+
},
68+
{
69+
Name: "dirty working copy",
70+
In: &GitInfo{
71+
dirty: true,
72+
},
73+
Files: []string{"foo"},
74+
Expectation: true,
75+
},
76+
{
77+
Name: "dirty file",
78+
In: &GitInfo{
79+
dirty: true,
80+
dirtyFiles: map[string]struct{}{
81+
"foo": {},
82+
},
83+
},
84+
Files: []string{"foo"},
85+
Expectation: true,
86+
},
87+
{
88+
Name: "dirty file loc",
89+
In: &GitInfo{
90+
WorkingCopyLoc: "bar/",
91+
dirty: true,
92+
dirtyFiles: map[string]struct{}{
93+
"foo": {},
94+
},
95+
},
96+
Files: []string{"bar/foo"},
97+
Expectation: true,
98+
},
99+
{
100+
Name: "unknown file",
101+
In: &GitInfo{
102+
WorkingCopyLoc: "bar/",
103+
dirty: true,
104+
dirtyFiles: map[string]struct{}{
105+
"foo": {},
106+
},
107+
},
108+
Files: []string{"not/in/this/working/copy"},
109+
Expectation: true,
110+
},
111+
{
112+
Name: "clean file",
113+
In: &GitInfo{
114+
dirty: true,
115+
dirtyFiles: map[string]struct{}{
116+
"foo": {},
117+
},
118+
},
119+
Files: []string{"bar"},
120+
Expectation: false,
121+
},
122+
}
123+
for _, test := range tests {
124+
t.Run(test.Name, func(t *testing.T) {
125+
act := test.In.DirtyFiles(test.Files)
126+
if diff := cmp.Diff(test.Expectation, act); diff != "" {
127+
t.Errorf("ParseGitStatus() mismatch (-want +got):\n%s", diff)
128+
}
129+
})
130+
}
131+
}

pkg/leeway/provenance.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const (
3636
// provenanceProcessVersion is the version of the provenance generating process.
3737
// If provenance is enabled in a workspace, this version becomes part of the manifest,
3838
// hence changing it will invalidate previously built packages.
39-
provenanceProcessVersion = 2
39+
provenanceProcessVersion = 3
4040

4141
// ProvenanceBuilderID is the prefix we use as Builder ID when issuing provenance
4242
ProvenanceBuilderID = "github.com/gitpod-io/leeway"
@@ -184,7 +184,7 @@ func (p *Package) produceSLSAEnvelope(buildctx *buildContext, subjects []in_toto
184184
now = time.Now()
185185
pred = provenance.NewSLSAPredicate()
186186
)
187-
if p.C.Git().Dirty {
187+
if p.C.Git().DirtyFiles(p.Sources) {
188188
files, err := p.inTotoMaterials()
189189
if err != nil {
190190
return nil, err

pkg/leeway/workspace.go

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,6 @@ type Workspace struct {
5050
ignores []string
5151
}
5252

53-
type GitInfo struct {
54-
Commit string
55-
Origin string
56-
Dirty bool
57-
}
58-
5953
type WorkspaceProvenance struct {
6054
Enabled bool `yaml:"enabled"`
6155
SLSA bool `yaml:"slsa"`
@@ -456,48 +450,6 @@ func loadWorkspace(ctx context.Context, path string, args Arguments, variant str
456450
return workspace, nil
457451
}
458452

459-
// GetGitInfo returns the git status required during a leeway build
460-
func GetGitInfo(loc string) (*GitInfo, error) {
461-
gitfc := filepath.Join(loc, ".git")
462-
stat, err := os.Stat(gitfc)
463-
if err != nil || !stat.IsDir() {
464-
return nil, nil
465-
}
466-
467-
var res GitInfo
468-
cmd := exec.Command("git", "rev-parse", "HEAD")
469-
cmd.Dir = loc
470-
out, err := cmd.CombinedOutput()
471-
if err != nil {
472-
return nil, err
473-
}
474-
res.Commit = strings.TrimSpace(string(out))
475-
476-
cmd = exec.Command("git", "config", "--get", "remote.origin.url")
477-
cmd.Dir = loc
478-
out, err = cmd.CombinedOutput()
479-
if err != nil && len(out) > 0 {
480-
return nil, err
481-
}
482-
res.Origin = strings.TrimSpace(string(out))
483-
484-
cmd = exec.Command("git", "status", "--short")
485-
cmd.Dir = loc
486-
out, err = cmd.CombinedOutput()
487-
if serr, ok := err.(*exec.ExitError); ok && serr.ExitCode() != 128 {
488-
// git status --short seems to exit with 128 all the time - that's ok, but we need to account for that.
489-
log.WithField("exitCode", serr.ExitCode()).Debug("git status --short exited with failed exit code. Working copy is dirty.")
490-
res.Dirty = true
491-
} else if _, ok := err.(*exec.ExitError); !ok && err != nil {
492-
return nil, err
493-
} else if len(strings.TrimSpace(string(out))) != 0 {
494-
res.Dirty = true
495-
log.WithField("out", string(out)).Debug("`git status --short` produced output. Working copy is dirty.")
496-
}
497-
498-
return &res, nil
499-
}
500-
501453
// buildEnvironmentManifest executes the commands of an env manifest and updates the values
502454
func buildEnvironmentManifest(entries EnvironmentManifest, pkgtpes map[PackageType]struct{}) (res EnvironmentManifest, err error) {
503455
t0 := time.Now()

0 commit comments

Comments
 (0)