feat: add internal reproducible git checksum builtin (#203)
Adds a new rego builtin `attest.internals.reproducible_git_checksum`. This is needed for verifying DOI provenance, see https://github.com/docker/doi-image-policy/blob/main/slsa.md#doi-build-reproducible-git-checksum. We use https://github.com/go-git/go-git for as much of this as possible, but it doesn't support the actual archive operation, so we shell out to `git` for that. There is some similar unexported code in bashbrew, and we should probably be using the same code in the build process as we are here. I'll create a follow-up ticket to sort that out.
This commit is contained in:
176
internal/git/git.go
Normal file
176
internal/git/git.go
Normal file
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
Copyright Docker attest authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
// GitCommand is the path to the git binary, overridden in tests to check behavior when git is not installed.
|
||||
var GitCommand = "git"
|
||||
|
||||
func Clone(ctx context.Context, gitRepo string, gitCommit string, targetDir string) error {
|
||||
const localBranch = "FETCH_HEAD"
|
||||
|
||||
repo, err := git.PlainInit(targetDir, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to init: %w", err)
|
||||
}
|
||||
|
||||
remote, err := repo.CreateRemote(&config.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{gitRepo},
|
||||
Fetch: []config.RefSpec{
|
||||
config.RefSpec(fmt.Sprintf("%s:%s", gitCommit, localBranch)),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add remote: %w", err)
|
||||
}
|
||||
|
||||
err = remote.FetchContext(ctx, &git.FetchOptions{
|
||||
Depth: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch: %w", err)
|
||||
}
|
||||
|
||||
wt, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get worktree: %w", err)
|
||||
}
|
||||
err = wt.Checkout(&git.CheckoutOptions{
|
||||
Branch: plumbing.ReferenceName(localBranch),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to checkout: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type execError struct {
|
||||
*exec.ExitError
|
||||
stderr []byte
|
||||
}
|
||||
|
||||
func (e *execError) Error() string {
|
||||
trimmed := bytes.TrimSpace(e.stderr)
|
||||
if len(trimmed) == 0 {
|
||||
return e.ExitError.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s, %q", e.ExitError.Error(), string(bytes.TrimSpace(e.stderr)))
|
||||
}
|
||||
|
||||
func (e *execError) Unwrap() error {
|
||||
return e.ExitError
|
||||
}
|
||||
|
||||
// Archive creates a tar archive of the files in the subdirectory given by subdir of the git repository at gitRepoDir.
|
||||
// This is accomplished by running `git archive --format=tar HEAD:subdir` in the git repository directory.
|
||||
//
|
||||
// The archive is written to the returned io.Reader. It is not necessary to close the returned reader.
|
||||
// Any error encountered while starting the command will be returned immediately.
|
||||
// Any error encountered after the command is running will be returned on the next read from the returned io.Reader.
|
||||
func Archive(ctx context.Context, gitRepoDir string, subdir string) (io.Reader, error) {
|
||||
readPipe, writePipe := io.Pipe()
|
||||
|
||||
treeish := fmt.Sprintf("HEAD:%s", subdir)
|
||||
cmd := exec.CommandContext(ctx, GitCommand, "archive", "--format=tar", treeish)
|
||||
// run the command inside the git repo directory
|
||||
cmd.Dir = gitRepoDir
|
||||
|
||||
// set the standard output to the write end of the pipe
|
||||
cmd.Stdout = writePipe
|
||||
|
||||
// capture standard error so we can include it in the error message if the command fails
|
||||
stderr := new(bytes.Buffer)
|
||||
cmd.Stderr = stderr
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
// spawn a goroutine to wait for the command to finish and close the write pipe
|
||||
go func() {
|
||||
var err error // variable to hold any error
|
||||
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
// if we panic, set err to a new error wrapping the panic value
|
||||
err = fmt.Errorf("panic: %v", p)
|
||||
}
|
||||
|
||||
// send any error from the command (or the panic above) to the write pipe
|
||||
// or nil if there was no error
|
||||
// this will cause the error to be returned on the next read from the read pipe
|
||||
writePipe.CloseWithError(err)
|
||||
}()
|
||||
|
||||
// wait for the command to finish and capture any error
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
err = &execError{ExitError: ee, stderr: stderr.Bytes()}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return readPipe, nil
|
||||
}
|
||||
|
||||
func TarScrub(in io.Reader, out io.Writer) error {
|
||||
tr := tar.NewReader(in)
|
||||
tw := tar.NewWriter(out)
|
||||
defer tw.Flush() // note: flush instead of close to avoid the empty block at EOF
|
||||
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newHdr := &tar.Header{
|
||||
Typeflag: hdr.Typeflag,
|
||||
Name: hdr.Name,
|
||||
Linkname: hdr.Linkname,
|
||||
Size: hdr.Size,
|
||||
Mode: hdr.Mode,
|
||||
Devmajor: hdr.Devmajor,
|
||||
Devminor: hdr.Devminor,
|
||||
}
|
||||
if err := tw.WriteHeader(newHdr); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.CopyN(tw, tr, hdr.Size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
42
internal/git/git_test.go
Normal file
42
internal/git/git_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright Docker attest authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestArchiveWithInvalidGitCommand(t *testing.T) {
|
||||
originalGitCommand := GitCommand
|
||||
GitCommand = "invalid-git-command"
|
||||
defer func() { GitCommand = originalGitCommand }()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "gitrepo")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err = Archive(ctx, tempDir, "")
|
||||
require.ErrorIs(t, err, exec.ErrNotFound)
|
||||
}
|
||||
Reference in New Issue
Block a user