1package getter
2
3import (
4	"bytes"
5	"context"
6	"encoding/base64"
7	"fmt"
8	"io/ioutil"
9	"net/url"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"regexp"
14	"runtime"
15	"strconv"
16	"strings"
17
18	urlhelper "github.com/hashicorp/go-getter/helper/url"
19	safetemp "github.com/hashicorp/go-safetemp"
20	version "github.com/hashicorp/go-version"
21)
22
23// GitGetter is a Getter implementation that will download a module from
24// a git repository.
25type GitGetter struct {
26	getter
27}
28
29var defaultBranchRegexp = regexp.MustCompile(`\s->\sorigin/(.*)`)
30
31func (g *GitGetter) ClientMode(_ *url.URL) (ClientMode, error) {
32	return ClientModeDir, nil
33}
34
35func (g *GitGetter) Get(dst string, u *url.URL) error {
36	ctx := g.Context()
37	if _, err := exec.LookPath("git"); err != nil {
38		return fmt.Errorf("git must be available and on the PATH")
39	}
40
41	// The port number must be parseable as an integer. If not, the user
42	// was probably trying to use a scp-style address, in which case the
43	// ssh:// prefix must be removed to indicate that.
44	//
45	// This is not necessary in versions of Go which have patched
46	// CVE-2019-14809 (e.g. Go 1.12.8+)
47	if portStr := u.Port(); portStr != "" {
48		if _, err := strconv.ParseUint(portStr, 10, 16); err != nil {
49			return fmt.Errorf("invalid port number %q; if using the \"scp-like\" git address scheme where a colon introduces the path instead, remove the ssh:// portion and use just the git:: prefix", portStr)
50		}
51	}
52
53	// Extract some query parameters we use
54	var ref, sshKey string
55	var depth int
56	q := u.Query()
57	if len(q) > 0 {
58		ref = q.Get("ref")
59		q.Del("ref")
60
61		sshKey = q.Get("sshkey")
62		q.Del("sshkey")
63
64		if n, err := strconv.Atoi(q.Get("depth")); err == nil {
65			depth = n
66		}
67		q.Del("depth")
68
69		// Copy the URL
70		var newU url.URL = *u
71		u = &newU
72		u.RawQuery = q.Encode()
73	}
74
75	var sshKeyFile string
76	if sshKey != "" {
77		// Check that the git version is sufficiently new.
78		if err := checkGitVersion("2.3"); err != nil {
79			return fmt.Errorf("Error using ssh key: %v", err)
80		}
81
82		// We have an SSH key - decode it.
83		raw, err := base64.StdEncoding.DecodeString(sshKey)
84		if err != nil {
85			return err
86		}
87
88		// Create a temp file for the key and ensure it is removed.
89		fh, err := ioutil.TempFile("", "go-getter")
90		if err != nil {
91			return err
92		}
93		sshKeyFile = fh.Name()
94		defer os.Remove(sshKeyFile)
95
96		// Set the permissions prior to writing the key material.
97		if err := os.Chmod(sshKeyFile, 0600); err != nil {
98			return err
99		}
100
101		// Write the raw key into the temp file.
102		_, err = fh.Write(raw)
103		fh.Close()
104		if err != nil {
105			return err
106		}
107	}
108
109	// Clone or update the repository
110	_, err := os.Stat(dst)
111	if err != nil && !os.IsNotExist(err) {
112		return err
113	}
114	if err == nil {
115		err = g.update(ctx, dst, sshKeyFile, ref, depth)
116	} else {
117		err = g.clone(ctx, dst, sshKeyFile, u, depth)
118	}
119	if err != nil {
120		return err
121	}
122
123	// Next: check out the proper tag/branch if it is specified, and checkout
124	if ref != "" {
125		if err := g.checkout(dst, ref); err != nil {
126			return err
127		}
128	}
129
130	// Lastly, download any/all submodules.
131	return g.fetchSubmodules(ctx, dst, sshKeyFile, depth)
132}
133
134// GetFile for Git doesn't support updating at this time. It will download
135// the file every time.
136func (g *GitGetter) GetFile(dst string, u *url.URL) error {
137	td, tdcloser, err := safetemp.Dir("", "getter")
138	if err != nil {
139		return err
140	}
141	defer tdcloser.Close()
142
143	// Get the filename, and strip the filename from the URL so we can
144	// just get the repository directly.
145	filename := filepath.Base(u.Path)
146	u.Path = filepath.Dir(u.Path)
147
148	// Get the full repository
149	if err := g.Get(td, u); err != nil {
150		return err
151	}
152
153	// Copy the single file
154	u, err = urlhelper.Parse(fmtFileURL(filepath.Join(td, filename)))
155	if err != nil {
156		return err
157	}
158
159	fg := &FileGetter{Copy: true}
160	return fg.GetFile(dst, u)
161}
162
163func (g *GitGetter) checkout(dst string, ref string) error {
164	cmd := exec.Command("git", "checkout", ref)
165	cmd.Dir = dst
166	return getRunCommand(cmd)
167}
168
169func (g *GitGetter) clone(ctx context.Context, dst, sshKeyFile string, u *url.URL, depth int) error {
170	args := []string{"clone"}
171
172	if depth > 0 {
173		args = append(args, "--depth", strconv.Itoa(depth))
174	}
175
176	args = append(args, u.String(), dst)
177	cmd := exec.CommandContext(ctx, "git", args...)
178	setupGitEnv(cmd, sshKeyFile)
179	return getRunCommand(cmd)
180}
181
182func (g *GitGetter) update(ctx context.Context, dst, sshKeyFile, ref string, depth int) error {
183	// Determine if we're a branch. If we're NOT a branch, then we just
184	// switch to master prior to checking out
185	cmd := exec.CommandContext(ctx, "git", "show-ref", "-q", "--verify", "refs/heads/"+ref)
186	cmd.Dir = dst
187
188	if getRunCommand(cmd) != nil {
189		// Not a branch, switch to default branch. This will also catch
190		// non-existent branches, in which case we want to switch to default
191		// and then checkout the proper branch later.
192		ref = findDefaultBranch(dst)
193	}
194
195	// We have to be on a branch to pull
196	if err := g.checkout(dst, ref); err != nil {
197		return err
198	}
199
200	if depth > 0 {
201		cmd = exec.Command("git", "pull", "--depth", strconv.Itoa(depth), "--ff-only")
202	} else {
203		cmd = exec.Command("git", "pull", "--ff-only")
204	}
205
206	cmd.Dir = dst
207	setupGitEnv(cmd, sshKeyFile)
208	return getRunCommand(cmd)
209}
210
211// fetchSubmodules downloads any configured submodules recursively.
212func (g *GitGetter) fetchSubmodules(ctx context.Context, dst, sshKeyFile string, depth int) error {
213	args := []string{"submodule", "update", "--init", "--recursive"}
214	if depth > 0 {
215		args = append(args, "--depth", strconv.Itoa(depth))
216	}
217	cmd := exec.CommandContext(ctx, "git", args...)
218	cmd.Dir = dst
219	setupGitEnv(cmd, sshKeyFile)
220	return getRunCommand(cmd)
221}
222
223// findDefaultBranch checks the repo's origin remote for its default branch
224// (generally "master"). "master" is returned if an origin default branch
225// can't be determined.
226func findDefaultBranch(dst string) string {
227	var stdoutbuf bytes.Buffer
228	cmd := exec.Command("git", "branch", "-r", "--points-at", "refs/remotes/origin/HEAD")
229	cmd.Dir = dst
230	cmd.Stdout = &stdoutbuf
231	err := cmd.Run()
232	matches := defaultBranchRegexp.FindStringSubmatch(stdoutbuf.String())
233	if err != nil || matches == nil {
234		return "master"
235	}
236	return matches[len(matches)-1]
237}
238
239// setupGitEnv sets up the environment for the given command. This is used to
240// pass configuration data to git and ssh and enables advanced cloning methods.
241func setupGitEnv(cmd *exec.Cmd, sshKeyFile string) {
242	const gitSSHCommand = "GIT_SSH_COMMAND="
243	var sshCmd []string
244
245	// If we have an existing GIT_SSH_COMMAND, we need to append our options.
246	// We will also remove our old entry to make sure the behavior is the same
247	// with versions of Go < 1.9.
248	env := os.Environ()
249	for i, v := range env {
250		if strings.HasPrefix(v, gitSSHCommand) && len(v) > len(gitSSHCommand) {
251			sshCmd = []string{v}
252
253			env[i], env[len(env)-1] = env[len(env)-1], env[i]
254			env = env[:len(env)-1]
255			break
256		}
257	}
258
259	if len(sshCmd) == 0 {
260		sshCmd = []string{gitSSHCommand + "ssh"}
261	}
262
263	if sshKeyFile != "" {
264		// We have an SSH key temp file configured, tell ssh about this.
265		if runtime.GOOS == "windows" {
266			sshKeyFile = strings.Replace(sshKeyFile, `\`, `/`, -1)
267		}
268		sshCmd = append(sshCmd, "-i", sshKeyFile)
269	}
270
271	env = append(env, strings.Join(sshCmd, " "))
272	cmd.Env = env
273}
274
275// checkGitVersion is used to check the version of git installed on the system
276// against a known minimum version. Returns an error if the installed version
277// is older than the given minimum.
278func checkGitVersion(min string) error {
279	want, err := version.NewVersion(min)
280	if err != nil {
281		return err
282	}
283
284	out, err := exec.Command("git", "version").Output()
285	if err != nil {
286		return err
287	}
288
289	fields := strings.Fields(string(out))
290	if len(fields) < 3 {
291		return fmt.Errorf("Unexpected 'git version' output: %q", string(out))
292	}
293	v := fields[2]
294	if runtime.GOOS == "windows" && strings.Contains(v, ".windows.") {
295		// on windows, git version will return for example:
296		// git version 2.20.1.windows.1
297		// Which does not follow the semantic versionning specs
298		// https://semver.org. We remove that part in order for
299		// go-version to not error.
300		v = v[:strings.Index(v, ".windows.")]
301	}
302
303	have, err := version.NewVersion(v)
304	if err != nil {
305		return err
306	}
307
308	if have.LessThan(want) {
309		return fmt.Errorf("Required git version = %s, have %s", want, have)
310	}
311
312	return nil
313}
314