diff --git a/cache/git.go b/cache/git.go index bdf505cf..6dadadf0 100644 --- a/cache/git.go +++ b/cache/git.go @@ -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) } @@ -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" } @@ -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 @@ -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] diff --git a/cache/source_test.go b/cache/source_test.go index 568a4577..b21f6bb6 100644 --- a/cache/source_test.go +++ b/cache/source_test.go @@ -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) { @@ -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