Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 85 additions & 6 deletions cache/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,27 @@ func (s *gitSource) Download(b *ui.Task, cache *Cache, checksum string) (string,
if err != nil {
return "", "", "", err
}
args := []string{"git", "clone", "--depth=1"}
if tag != "" {
args = append(args, "--branch="+tag)
pinned := false
if isFullGitSHA(tag) {
// A full-hex name may still be a valid branch or tag name. Only treat
// it as a commit pin when the remote does not advertise a ref with
// that exact name, preserving prior "git clone --branch" behaviour.
advertised, aerr := resolveAdvertisedRef(b, repo, tag)
if aerr != nil {
return "", "", "", aerr
}
pinned = advertised == ""
}
if pinned {
err = checkoutGitCommit(b, cache.root, repo, tag, checkoutDir)
} else {
args := []string{"git", "clone", "--depth=1"}
if tag != "" {
args = append(args, "--branch="+tag)
}
args = append(args, "--", repo, checkoutDir)
err = util.RunInDir(b, cache.root, args...)
}
args = append(args, "--", repo, checkoutDir)
err = util.RunInDir(b, cache.root, args...)
if err != nil {
return "", "", "", errors.WithStack(err)
}
Expand All @@ -51,6 +66,18 @@ func (s *gitSource) ETag(b *ui.Task) (etag string, err error) {
if err != nil {
return "", err
}
if isFullGitSHA(tag) {
advertised, err := resolveAdvertisedRef(b, repo, tag)
if err != nil {
return "", errors.Wrap(err, s.URL)
}
if advertised != "" {
return advertised, nil
}
// Not an advertised branch or tag, so it is a pinned commit, which is
// immutable and its own ETag.
return tag, nil
}
if tag == "" {
tag = "HEAD"
}
Expand All @@ -72,7 +99,9 @@ func (s *gitSource) Validate() error {
if err != nil {
return err
}
if tag == "" {
if tag == "" || isFullGitSHA(tag) {
// A commit SHA cannot be listed with ls-remote, so just verify that
// the repository is reachable.
tag = "HEAD"
}
cmd := exec.Command("git", "ls-remote", "--", repo, tag) //nolint
Expand All @@ -83,6 +112,56 @@ func (s *gitSource) Validate() error {
return nil
}

// resolveAdvertisedRef returns the commit the remote advertises for the
// branch or tag with the exact name ref, or "" when the remote advertises no
// such ref. Branches take precedence over tags, matching "git clone --branch".
func resolveAdvertisedRef(b *ui.Task, repo, ref string) (string, error) {
bts, err := util.Capture(b, "git", "ls-remote", "--", repo, "refs/heads/"+ref, "refs/tags/"+ref)
if err != nil {
return "", errors.WithStack(err)
}
out := strings.TrimSpace(string(bts))
if out == "" {
return "", nil
}
// ls-remote output is sorted by ref name, so refs/heads sorts first.
line, _, _ := strings.Cut(out, "\n")
sha, _, ok := strings.Cut(line, "\t")
if !ok {
return "", errors.Errorf("invalid ls-remote output: %s", line)
}
return sha, nil
}

// checkoutGitCommit fetches a single commit by SHA and checks it out.
//
// A commit SHA cannot be passed to "git clone --branch", so initialise an
// empty repository and fetch just the commit instead. This requires the
// server to allow fetching by commit SHA (GitHub and GitLab both do).
func checkoutGitCommit(b *ui.Task, root, repo, sha, checkoutDir string) error {
if err := util.RunInDir(b, root, "git", "init", "--", checkoutDir); err != nil {
return errors.WithStack(err)
}
if err := util.RunInDir(b, checkoutDir, "git", "fetch", "--depth=1", "--", repo, sha); err != nil {
return errors.WithStack(err)
}
return errors.WithStack(util.RunInDir(b, checkoutDir, "git", "checkout", "--detach", "FETCH_HEAD"))
}

// isFullGitSHA reports whether ref is a full lowercase hex commit hash
// (40 characters for SHA-1, 64 for SHA-256).
func isFullGitSHA(ref string) bool {
if len(ref) != 40 && len(ref) != 64 {
return false
}
for _, r := range ref {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}

func parseGitURL(source string) (repo, tag string, err error) {
parts := strings.SplitN(source, "#", 2)
repo = parts[0]
Expand Down
97 changes: 97 additions & 0 deletions cache/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/alecthomas/assert/v2"

"github.com/cashapp/hermit/ui"
)

func TestGitParseRepo(t *testing.T) {
Expand Down Expand Up @@ -90,6 +93,100 @@ func TestGitSourceRCEAttempt(t *testing.T) {
}
}

func TestIsFullGitSHA(t *testing.T) {
tests := []struct {
ref string
want bool
}{
{"6bccbcae2934bdd10ede93d493ee1eeeef5f24e2", true},
{strings.Repeat("a", 64), true},
{"", false},
{"main", false},
{"v1.2.3", false},
// Abbreviated SHAs are indistinguishable from branch names.
{"6bccbca", false},
{strings.Repeat("a", 39), false},
{strings.Repeat("a", 41), false},
// Git prints SHAs in lowercase.
{strings.Repeat("A", 40), false},
{strings.Repeat("g", 40), false},
}
for _, tt := range tests {
assert.Equal(t, tt.want, isFullGitSHA(tt.ref), tt.ref)
}
}

// TestGitSourceCommitSHAPinning verifies that a git source can be pinned to a
// full commit SHA rather than a branch or tag.
func TestGitSourceCommitSHAPinning(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git command not found")
}

tmpDir := t.TempDir()
repoDir := filepath.Join(tmpDir, "repo")
assert.NoError(t, os.MkdirAll(repoDir, 0750))
mustGit := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repoDir
out, err := cmd.CombinedOutput()
assert.NoError(t, err, string(out))
return strings.TrimSpace(string(out))
}
mustGit("init")
mustGit("config", "user.email", "test@example.com")
mustGit("config", "user.name", "Test")
// Fetching an unadvertised commit from a local repository requires this;
// hosting services such as GitHub and GitLab allow it by default.
mustGit("config", "uploadpack.allowReachableSHA1InWant", "true")
assert.NoError(t, os.WriteFile(filepath.Join(repoDir, "file.txt"), []byte("first"), 0600))
mustGit("add", "file.txt")
mustGit("commit", "-m", "first")
pinned := mustGit("rev-parse", "HEAD")
assert.NoError(t, os.WriteFile(filepath.Join(repoDir, "file.txt"), []byte("second"), 0600))
mustGit("add", "file.txt")
mustGit("commit", "-m", "second")

src := &gitSource{URL: "file://" + repoDir + "#" + pinned}
log, _ := ui.NewForTesting()

// The remote advertises no branch or tag by this name, so it is treated
// as a pinned commit and is its own ETag.
etag, err := src.ETag(log.Task("test"))
assert.NoError(t, err)
assert.Equal(t, pinned, etag)

assert.NoError(t, src.Validate())

cacheRoot := filepath.Join(tmpDir, "cache")
assert.NoError(t, os.MkdirAll(cacheRoot, 0750))
cache := &Cache{root: cacheRoot}
dir, etag, _, err := src.Download(log.Task("test"), cache, "checksum")
assert.NoError(t, err)
assert.Equal(t, pinned, etag)
content, err := os.ReadFile(filepath.Join(dir, "file.txt"))
assert.NoError(t, err)
assert.Equal(t, "first", string(content))

// A branch whose name is exactly a full-hex string must still resolve as
// a branch, not be reinterpreted as a commit object ID.
hexBranch := strings.Repeat("a", 40)
mustGit("branch", hexBranch, "HEAD")
branchSrc := &gitSource{URL: "file://" + repoDir + "#" + hexBranch}

etag, err = branchSrc.ETag(log.Task("test"))
assert.NoError(t, err)
assert.Equal(t, mustGit("rev-parse", "HEAD"), etag)

dir, etag, _, err = branchSrc.Download(log.Task("test"), cache, "checksum2")
assert.NoError(t, err)
assert.Equal(t, mustGit("rev-parse", "HEAD"), etag)
content, err = os.ReadFile(filepath.Join(dir, "file.txt"))
assert.NoError(t, err)
assert.Equal(t, "second", string(content))
}

func TestGitURLParsing(t *testing.T) {
tests := []struct {
url string
Expand Down