Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add GitHub git compatibility mode #4474

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
97 changes: 92 additions & 5 deletions clients/githubrepo/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"github.com/ossf/scorecard/v5/clients"
"github.com/ossf/scorecard/v5/clients/githubrepo/roundtripper"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/internal/gitfile"
"github.com/ossf/scorecard/v5/log"
)

Expand All @@ -40,6 +41,8 @@
errDefaultBranchEmpty = errors.New("default branch name is empty")
)

type Option func(*repoClientConfig) error

// Client is GitHub-specific implementation of RepoClient.
type Client struct {
repourl *Repo
Expand All @@ -57,9 +60,32 @@
webhook *webhookHandler
languages *languagesHandler
licenses *licensesHandler
git *gitfile.Handler
ctx context.Context
tarball tarballHandler
commitDepth int
gitMode bool
}

// WithFileModeGit configures the repo client to fetch files using git.
func WithFileModeGit() Option {
return func(c *repoClientConfig) error {
c.gitMode = true
return nil
}

Check warning on line 75 in clients/githubrepo/client.go

View check run for this annotation

Codecov / codecov/patch

clients/githubrepo/client.go#L71-L75

Added lines #L71 - L75 were not covered by tests
}

// WithRoundTripper configures the repo client to use the specified http.RoundTripper.
func WithRoundTripper(rt http.RoundTripper) Option {
return func(c *repoClientConfig) error {
c.rt = rt
return nil
}
}

type repoClientConfig struct {
rt http.RoundTripper
gitMode bool
}

const defaultGhHost = "github.com"
Expand Down Expand Up @@ -88,8 +114,12 @@
commitSHA: commitSHA,
}

// Init tarballHandler.
client.tarball.init(client.ctx, client.repo, commitSHA)
if client.gitMode {
client.git.Init(client.ctx, client.repo.GetCloneURL(), commitSHA)
} else {
// Init tarballHandler.
client.tarball.init(client.ctx, client.repo, commitSHA)
}

Check warning on line 122 in clients/githubrepo/client.go

View check run for this annotation

Codecov / codecov/patch

clients/githubrepo/client.go#L117-L122

Added lines #L117 - L122 were not covered by tests

// Setup GraphQL.
client.graphClient.init(client.ctx, client.repourl, client.commitDepth)
Expand Down Expand Up @@ -141,16 +171,37 @@

// LocalPath implements RepoClient.LocalPath.
func (client *Client) LocalPath() (string, error) {
if client.gitMode {
path, err := client.git.GetLocalPath()
if err != nil {
return "", fmt.Errorf("git local path: %w", err)
}
return path, nil

Check warning on line 179 in clients/githubrepo/client.go

View check run for this annotation

Codecov / codecov/patch

clients/githubrepo/client.go#L174-L179

Added lines #L174 - L179 were not covered by tests
}
return client.tarball.getLocalPath()
}

// ListFiles implements RepoClient.ListFiles.
func (client *Client) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
if client.gitMode {
files, err := client.git.ListFiles(predicate)
if err != nil {
return nil, fmt.Errorf("git listfiles: %w", err)
}
return files, nil

Check warning on line 191 in clients/githubrepo/client.go

View check run for this annotation

Codecov / codecov/patch

clients/githubrepo/client.go#L186-L191

Added lines #L186 - L191 were not covered by tests
}
return client.tarball.listFiles(predicate)
}

// GetFileReader implements RepoClient.GetFileReader.
func (client *Client) GetFileReader(filename string) (io.ReadCloser, error) {
if client.gitMode {
f, err := client.git.GetFile(filename)
if err != nil {
return nil, fmt.Errorf("git getfile: %w", err)
}
return f, nil

Check warning on line 203 in clients/githubrepo/client.go

View check run for this annotation

Codecov / codecov/patch

clients/githubrepo/client.go#L198-L203

Added lines #L198 - L203 were not covered by tests
}
return client.tarball.getFile(filename)
}

Expand Down Expand Up @@ -210,7 +261,14 @@
return nil, fmt.Errorf("error during MakeGithubRepo: %w", err)
}

c := CreateGithubRepoClientWithTransport(ctx, client.repoClient.Client().Transport)
options := []Option{WithRoundTripper(client.repoClient.Client().Transport)}
if client.gitMode {
options = append(options, WithFileModeGit())
}
c, err := NewRepoClient(ctx, options...)
if err != nil {
return nil, fmt.Errorf("create org repoclient: %w", err)
}

Check warning on line 271 in clients/githubrepo/client.go

View check run for this annotation

Codecov / codecov/patch

clients/githubrepo/client.go#L264-L271

Added lines #L264 - L271 were not covered by tests
if err := c.InitRepo(dotGithubRepo, clients.HeadSHA, 0); err != nil {
return nil, fmt.Errorf("error during InitRepo: %w", err)
}
Expand Down Expand Up @@ -260,13 +318,40 @@

// Close implements RepoClient.Close.
func (client *Client) Close() error {
if client.gitMode {
if err := client.git.Cleanup(); err != nil {
return fmt.Errorf("git cleanup: %w", err)
}
return nil

Check warning on line 325 in clients/githubrepo/client.go

View check run for this annotation

Codecov / codecov/patch

clients/githubrepo/client.go#L321-L325

Added lines #L321 - L325 were not covered by tests
}
return client.tarball.cleanup()
}

// CreateGithubRepoClientWithTransport returns a Client which implements RepoClient interface.
func CreateGithubRepoClientWithTransport(ctx context.Context, rt http.RoundTripper) clients.RepoClient {
//nolint:errcheck // need to suppress because this method doesn't return an error
rc, _ := NewRepoClient(ctx, WithRoundTripper(rt))
return rc
}

// NewRepoClient returns a Client which implements RepoClient interface.
// It can be configured with various [Option]s.
func NewRepoClient(ctx context.Context, opts ...Option) (clients.RepoClient, error) {
var config repoClientConfig

for _, option := range opts {
if err := option(&config); err != nil {
return nil, err
}

Check warning on line 345 in clients/githubrepo/client.go

View check run for this annotation

Codecov / codecov/patch

clients/githubrepo/client.go#L344-L345

Added lines #L344 - L345 were not covered by tests
}

if config.rt == nil {
logger := log.NewLogger(log.DefaultLevel)
config.rt = roundtripper.NewTransport(ctx, logger)
}

Check warning on line 351 in clients/githubrepo/client.go

View check run for this annotation

Codecov / codecov/patch

clients/githubrepo/client.go#L349-L351

Added lines #L349 - L351 were not covered by tests

httpClient := &http.Client{
Transport: rt,
Transport: config.rt,
}

var client *github.Client
Expand Down Expand Up @@ -333,7 +418,9 @@
tarball: tarballHandler{
httpClient: httpClient,
},
}
gitMode: config.gitMode,
git: &gitfile.Handler{},
}, nil
}

// CreateGithubRepoClient returns a Client which implements RepoClient interface.
Expand Down
9 changes: 7 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,18 @@
}
}

repoResult, err = scorecard.Run(ctx, repo,
opts := []scorecard.Option{

Check warning on line 150 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L150

Added line #L150 was not covered by tests
scorecard.WithLogLevel(sclog.ParseLevel(o.LogLevel)),
scorecard.WithCommitSHA(o.Commit),
scorecard.WithCommitDepth(o.CommitDepth),
scorecard.WithProbes(enabledProbes),
scorecard.WithChecks(checks),
)
}
if strings.EqualFold(o.FileMode, options.FileModeGit) {
opts = append(opts, scorecard.WithFileModeGit())
}

Check warning on line 159 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L156-L159

Added lines #L156 - L159 were not covered by tests

repoResult, err = scorecard.Run(ctx, repo, opts...)

Check warning on line 161 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L161

Added line #L161 was not covered by tests
if err != nil {
return fmt.Errorf("scorecard.Run: %w", err)
}
Expand Down
160 changes: 160 additions & 0 deletions internal/gitfile/gitfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright 2025 OpenSSF Scorecard 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 gitfile defines functionality to list and fetch files after temporarily cloning a git repo.
package gitfile

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"

"github.com/ossf/scorecard/v5/clients"
)

var errPathTraversal = errors.New("requested file outside repo")

const repoDir = "repo*"

type Handler struct {
errSetup error
ctx context.Context
once *sync.Once
cloneURL string
gitRepo *git.Repository
tempDir string
commitSHA string
}

func (h *Handler) Init(ctx context.Context, cloneURL, commitSHA string) {
h.errSetup = nil
h.once = new(sync.Once)
h.ctx = ctx
h.cloneURL = cloneURL
h.commitSHA = commitSHA
}

func (h *Handler) setup() error {
h.once.Do(func() {
tempDir, err := os.MkdirTemp("", repoDir)
if err != nil {
h.errSetup = err
return
}

Check warning on line 62 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L60-L62

Added lines #L60 - L62 were not covered by tests
h.tempDir = tempDir
h.gitRepo, err = git.PlainClone(h.tempDir, false, &git.CloneOptions{
URL: h.cloneURL,
// TODO: auth may be required for private repos
Depth: 1, // currently only use the git repo for files, dont need history
SingleBranch: true,
})
if err != nil {
h.errSetup = err
return
}

Check warning on line 73 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L71-L73

Added lines #L71 - L73 were not covered by tests

// assume the commit SHA is reachable from the default branch
// this isn't as flexible as the tarball handler, but good enough for now
if h.commitSHA != clients.HeadSHA {
wt, err := h.gitRepo.Worktree()
if err != nil {
h.errSetup = err
return
}
if err := wt.Checkout(&git.CheckoutOptions{Hash: plumbing.NewHash(h.commitSHA)}); err != nil {
h.errSetup = fmt.Errorf("checkout specified commit: %w", err)
return
}

Check warning on line 86 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L78-L86

Added lines #L78 - L86 were not covered by tests
}
})
return h.errSetup
}

func (h *Handler) GetLocalPath() (string, error) {
if err := h.setup(); err != nil {
return "", fmt.Errorf("setup: %w", err)
}
return h.tempDir, nil

Check warning on line 96 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L92-L96

Added lines #L92 - L96 were not covered by tests
}

func (h *Handler) ListFiles(predicate func(string) (bool, error)) ([]string, error) {
if err := h.setup(); err != nil {
return nil, fmt.Errorf("setup: %w", err)
}

Check warning on line 102 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L101-L102

Added lines #L101 - L102 were not covered by tests
ref, err := h.gitRepo.Head()
if err != nil {
return nil, fmt.Errorf("git.Head: %w", err)
}

Check warning on line 106 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L105-L106

Added lines #L105 - L106 were not covered by tests

commit, err := h.gitRepo.CommitObject(ref.Hash())
if err != nil {
return nil, fmt.Errorf("git.CommitObject: %w", err)
}

Check warning on line 111 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L110-L111

Added lines #L110 - L111 were not covered by tests

tree, err := commit.Tree()
if err != nil {
return nil, fmt.Errorf("git.Commit.Tree: %w", err)
}

Check warning on line 116 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L115-L116

Added lines #L115 - L116 were not covered by tests

var files []string
err = tree.Files().ForEach(func(f *object.File) error {
shouldInclude, err := predicate(f.Name)
if err != nil {
return fmt.Errorf("error applying predicate to file %s: %w", f.Name, err)
}

Check warning on line 123 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L122-L123

Added lines #L122 - L123 were not covered by tests

if shouldInclude {
files = append(files, f.Name)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("git.Tree.Files: %w", err)
}

Check warning on line 132 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L131-L132

Added lines #L131 - L132 were not covered by tests

return files, nil
}

func (h *Handler) GetFile(filename string) (*os.File, error) {
if err := h.setup(); err != nil {
return nil, fmt.Errorf("setup: %w", err)
}

Check warning on line 140 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L139-L140

Added lines #L139 - L140 were not covered by tests

// check for path traversal
path := filepath.Join(h.tempDir, filename)
if !strings.HasPrefix(path, filepath.Clean(h.tempDir)+string(os.PathSeparator)) {
return nil, errPathTraversal
}

f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}

Check warning on line 151 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L150-L151

Added lines #L150 - L151 were not covered by tests
return f, nil
}

func (h *Handler) Cleanup() error {
if err := os.RemoveAll(h.tempDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("os.Remove: %w", err)
}

Check warning on line 158 in internal/gitfile/gitfile.go

View check run for this annotation

Codecov / codecov/patch

internal/gitfile/gitfile.go#L157-L158

Added lines #L157 - L158 were not covered by tests
return nil
}
Loading
Loading