gopkg.in/docker/docker.v20@v20.10.27/builder/remotecontext/git/gitutils.go (about)

     1  package git // import "github.com/docker/docker/builder/remotecontext/git"
     2  
     3  import (
     4  	"net/http"
     5  	"net/url"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  
    10  	"github.com/moby/sys/symlink"
    11  	"github.com/pkg/errors"
    12  	exec "golang.org/x/sys/execabs"
    13  )
    14  
    15  type gitRepo struct {
    16  	remote string
    17  	ref    string
    18  	subdir string
    19  
    20  	isolateConfig bool
    21  }
    22  
    23  // CloneOption changes the behaviour of Clone().
    24  type CloneOption func(*gitRepo)
    25  
    26  // WithIsolatedConfig disables reading the user or system gitconfig files when
    27  // performing Git operations.
    28  func WithIsolatedConfig(v bool) CloneOption {
    29  	return func(gr *gitRepo) {
    30  		gr.isolateConfig = v
    31  	}
    32  }
    33  
    34  // Clone clones a repository into a newly created directory which
    35  // will be under "docker-build-git"
    36  func Clone(remoteURL string, opts ...CloneOption) (string, error) {
    37  	repo, err := parseRemoteURL(remoteURL)
    38  
    39  	if err != nil {
    40  		return "", err
    41  	}
    42  
    43  	for _, opt := range opts {
    44  		opt(&repo)
    45  	}
    46  
    47  	return repo.clone()
    48  }
    49  
    50  func (repo gitRepo) clone() (checkoutDir string, err error) {
    51  	fetch := fetchArgs(repo.remote, repo.ref)
    52  
    53  	root, err := os.MkdirTemp("", "docker-build-git")
    54  	if err != nil {
    55  		return "", err
    56  	}
    57  
    58  	defer func() {
    59  		if err != nil {
    60  			os.RemoveAll(root)
    61  		}
    62  	}()
    63  
    64  	if out, err := repo.gitWithinDir(root, "init"); err != nil {
    65  		return "", errors.Wrapf(err, "failed to init repo at %s: %s", root, out)
    66  	}
    67  
    68  	// Add origin remote for compatibility with previous implementation that
    69  	// used "git clone" and also to make sure local refs are created for branches
    70  	if out, err := repo.gitWithinDir(root, "remote", "add", "origin", repo.remote); err != nil {
    71  		return "", errors.Wrapf(err, "failed add origin repo at %s: %s", repo.remote, out)
    72  	}
    73  
    74  	if output, err := repo.gitWithinDir(root, fetch...); err != nil {
    75  		return "", errors.Wrapf(err, "error fetching: %s", output)
    76  	}
    77  
    78  	checkoutDir, err = repo.checkout(root)
    79  	if err != nil {
    80  		return "", err
    81  	}
    82  
    83  	cmd := exec.Command("git", "submodule", "update", "--init", "--recursive", "--depth=1")
    84  	cmd.Dir = root
    85  	output, err := cmd.CombinedOutput()
    86  	if err != nil {
    87  		return "", errors.Wrapf(err, "error initializing submodules: %s", output)
    88  	}
    89  
    90  	return checkoutDir, nil
    91  }
    92  
    93  func parseRemoteURL(remoteURL string) (gitRepo, error) {
    94  	repo := gitRepo{}
    95  
    96  	if !isGitTransport(remoteURL) {
    97  		remoteURL = "https://" + remoteURL
    98  	}
    99  
   100  	var fragment string
   101  	if strings.HasPrefix(remoteURL, "git@") {
   102  		// git@.. is not an URL, so cannot be parsed as URL
   103  		parts := strings.SplitN(remoteURL, "#", 2)
   104  
   105  		repo.remote = parts[0]
   106  		if len(parts) == 2 {
   107  			fragment = parts[1]
   108  		}
   109  		repo.ref, repo.subdir = getRefAndSubdir(fragment)
   110  	} else {
   111  		u, err := url.Parse(remoteURL)
   112  		if err != nil {
   113  			return repo, err
   114  		}
   115  
   116  		repo.ref, repo.subdir = getRefAndSubdir(u.Fragment)
   117  		u.Fragment = ""
   118  		repo.remote = u.String()
   119  	}
   120  
   121  	if strings.HasPrefix(repo.ref, "-") {
   122  		return gitRepo{}, errors.Errorf("invalid refspec: %s", repo.ref)
   123  	}
   124  
   125  	return repo, nil
   126  }
   127  
   128  func getRefAndSubdir(fragment string) (ref string, subdir string) {
   129  	refAndDir := strings.SplitN(fragment, ":", 2)
   130  	ref = "master"
   131  	if len(refAndDir[0]) != 0 {
   132  		ref = refAndDir[0]
   133  	}
   134  	if len(refAndDir) > 1 && len(refAndDir[1]) != 0 {
   135  		subdir = refAndDir[1]
   136  	}
   137  	return
   138  }
   139  
   140  func fetchArgs(remoteURL string, ref string) []string {
   141  	args := []string{"fetch"}
   142  
   143  	if supportsShallowClone(remoteURL) {
   144  		args = append(args, "--depth", "1")
   145  	}
   146  
   147  	return append(args, "origin", "--", ref)
   148  }
   149  
   150  // Check if a given git URL supports a shallow git clone,
   151  // i.e. it is a non-HTTP server or a smart HTTP server.
   152  func supportsShallowClone(remoteURL string) bool {
   153  	if scheme := getScheme(remoteURL); scheme == "http" || scheme == "https" {
   154  		// Check if the HTTP server is smart
   155  
   156  		// Smart servers must correctly respond to a query for the git-upload-pack service
   157  		serviceURL := remoteURL + "/info/refs?service=git-upload-pack"
   158  
   159  		// Try a HEAD request and fallback to a Get request on error
   160  		res, err := http.Head(serviceURL) // #nosec G107
   161  		if err != nil || res.StatusCode != http.StatusOK {
   162  			res, err = http.Get(serviceURL) // #nosec G107
   163  			if err == nil {
   164  				res.Body.Close()
   165  			}
   166  			if err != nil || res.StatusCode != http.StatusOK {
   167  				// request failed
   168  				return false
   169  			}
   170  		}
   171  
   172  		if res.Header.Get("Content-Type") != "application/x-git-upload-pack-advertisement" {
   173  			// Fallback, not a smart server
   174  			return false
   175  		}
   176  		return true
   177  	}
   178  	// Non-HTTP protocols always support shallow clones
   179  	return true
   180  }
   181  
   182  func (repo gitRepo) checkout(root string) (string, error) {
   183  	// Try checking out by ref name first. This will work on branches and sets
   184  	// .git/HEAD to the current branch name
   185  	if output, err := repo.gitWithinDir(root, "checkout", repo.ref); err != nil {
   186  		// If checking out by branch name fails check out the last fetched ref
   187  		if _, err2 := repo.gitWithinDir(root, "checkout", "FETCH_HEAD"); err2 != nil {
   188  			return "", errors.Wrapf(err, "error checking out %s: %s", repo.ref, output)
   189  		}
   190  	}
   191  
   192  	if repo.subdir != "" {
   193  		newCtx, err := symlink.FollowSymlinkInScope(filepath.Join(root, repo.subdir), root)
   194  		if err != nil {
   195  			return "", errors.Wrapf(err, "error setting git context, %q not within git root", repo.subdir)
   196  		}
   197  
   198  		fi, err := os.Stat(newCtx)
   199  		if err != nil {
   200  			return "", err
   201  		}
   202  		if !fi.IsDir() {
   203  			return "", errors.Errorf("error setting git context, not a directory: %s", newCtx)
   204  		}
   205  		root = newCtx
   206  	}
   207  
   208  	return root, nil
   209  }
   210  
   211  func (repo gitRepo) gitWithinDir(dir string, args ...string) ([]byte, error) {
   212  	args = append([]string{"-c", "protocol.file.allow=never"}, args...) // Block sneaky repositories from using repos from the filesystem as submodules.
   213  	cmd := exec.Command("git", args...)
   214  	cmd.Dir = dir
   215  	// Disable unsafe remote protocols.
   216  	cmd.Env = append(os.Environ(), "GIT_PROTOCOL_FROM_USER=0")
   217  
   218  	if repo.isolateConfig {
   219  		cmd.Env = append(cmd.Env,
   220  			"GIT_CONFIG_NOSYSTEM=1", // Disable reading from system gitconfig.
   221  			"HOME=/dev/null",        // Disable reading from user gitconfig.
   222  		)
   223  	}
   224  
   225  	return cmd.CombinedOutput()
   226  }
   227  
   228  // isGitTransport returns true if the provided str is a git transport by inspecting
   229  // the prefix of the string for known protocols used in git.
   230  func isGitTransport(str string) bool {
   231  	if strings.HasPrefix(str, "git@") {
   232  		return true
   233  	}
   234  
   235  	switch getScheme(str) {
   236  	case "git", "http", "https", "ssh":
   237  		return true
   238  	}
   239  
   240  	return false
   241  }
   242  
   243  // getScheme returns addresses' scheme in lowercase, or an empty
   244  // string in case address is an invalid URL.
   245  func getScheme(address string) string {
   246  	u, err := url.Parse(address)
   247  	if err != nil {
   248  		return ""
   249  	}
   250  	return u.Scheme
   251  }