diff options
Diffstat (limited to 'ext/git')
| -rw-r--r-- | ext/git/client.go | 328 | ||||
| -rw-r--r-- | ext/git/client_test.go | 263 | ||||
| -rw-r--r-- | ext/git/creds.go | 225 | ||||
| -rw-r--r-- | ext/git/creds_test.go | 369 | ||||
| -rw-r--r-- | ext/git/git.go | 10 | ||||
| -rw-r--r-- | ext/git/git_test.go | 163 | ||||
| -rw-r--r-- | ext/git/mocks/Client.go | 217 | ||||
| -rw-r--r-- | ext/git/ssh.go | 8 | ||||
| -rw-r--r-- | ext/git/workaround.go | 20 | ||||
| -rw-r--r-- | ext/git/writer.go | 2 |
10 files changed, 1418 insertions, 187 deletions
diff --git a/ext/git/client.go b/ext/git/client.go index 11a9a75..ac9dc10 100644 --- a/ext/git/client.go +++ b/ext/git/client.go @@ -13,24 +13,33 @@ import ( "sort" "strconv" "strings" + "syscall" "time" + argoexec "github.com/argoproj/pkg/exec" + "github.com/bmatcuk/doublestar/v4" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" + "github.com/google/uuid" log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" + apierrors "k8s.io/apimachinery/pkg/api/errors" + utilnet "k8s.io/apimachinery/pkg/util/net" "github.com/argoproj/argo-cd/v2/common" certutil "github.com/argoproj/argo-cd/v2/util/cert" + "github.com/argoproj/argo-cd/v2/util/env" executil "github.com/argoproj/argo-cd/v2/util/exec" "github.com/argoproj/argo-cd/v2/util/proxy" ) +var ErrInvalidRepoURL = fmt.Errorf("repo URL is invalid") + type RevisionMetadata struct { Author string Date time.Time @@ -47,7 +56,8 @@ type Refs struct { type gitRefCache interface { SetGitReferences(repo string, references []*plumbing.Reference) error - GetGitReferences(repo string, references *[]*plumbing.Reference) error + GetOrLockGitReferences(repo string, lockId string, references *[]*plumbing.Reference) (string, error) + UnlockGitReferences(repo string, lockId string) error } // Client is a generic git client interface @@ -55,14 +65,17 @@ type Client interface { Root() string Init() error Fetch(revision string) error - Checkout(revision string) error + Submodule() error + Checkout(revision string, submoduleEnabled bool) error LsRefs() (*Refs, error) LsRemote(revision string) (string, error) - LsFiles(path string) ([]string, error) + LsFiles(path string, enableNewGitFileGlobbing bool) ([]string, error) LsLargeFiles() ([]string, error) CommitSHA() (string, error) RevisionMetadata(revision string) (*RevisionMetadata, error) VerifyCommitSignature(string) (string, error) + IsAnnotatedTag(string) bool + ChangedFiles(revision string, targetRevision string) ([]string, error) Commit(pathSpec string, opts *CommitOptions) error Branch(sourceBranch string, targetBranch string) error Push(remote string, branch string, force bool) error @@ -98,8 +111,16 @@ type nativeGitClient struct { proxy string } +type runOpts struct { + SkipErrorLogging bool + CaptureStderr bool +} + var ( maxAttemptsCount = 1 + maxRetryDuration time.Duration + retryDuration time.Duration + factor int64 ) func init() { @@ -110,6 +131,11 @@ func init() { maxAttemptsCount = int(math.Max(float64(cnt), 1)) } } + + maxRetryDuration = env.ParseDurationFromEnv(common.EnvGitRetryMaxDuration, common.DefaultGitRetryMaxDuration, 0, math.MaxInt64) + retryDuration = env.ParseDurationFromEnv(common.EnvGitRetryDuration, common.DefaultGitRetryDuration, 0, math.MaxInt64) + factor = env.ParseInt64FromEnv(common.EnvGitRetryFactor, common.DefaultGitRetryFactor, 0, math.MaxInt64) + } type ClientOpts func(c *nativeGitClient) @@ -131,9 +157,13 @@ func WithEventHandlers(handlers EventHandlers) ClientOpts { func NewClient(rawRepoURL string, creds Creds, insecure bool, enableLfs bool, proxy string, opts ...ClientOpts) (Client, error) { r := regexp.MustCompile("(/|:)") - root := filepath.Join(os.TempDir(), r.ReplaceAllString(NormalizeGitURL(rawRepoURL), "_")) + normalizedGitURL := NormalizeGitURL(rawRepoURL) + if normalizedGitURL == "" { + return nil, fmt.Errorf("repository %q cannot be initialized: %w", rawRepoURL, ErrInvalidRepoURL) + } + root := filepath.Join(os.TempDir(), r.ReplaceAllString(normalizedGitURL, "_")) if root == os.TempDir() { - return nil, fmt.Errorf("Repository '%s' cannot be initialized, because its root would be system temp at %s", rawRepoURL, root) + return nil, fmt.Errorf("repository %q cannot be initialized, because its root would be system temp at %s", rawRepoURL, root) } return NewClientExt(rawRepoURL, root, creds, insecure, enableLfs, proxy, opts...) } @@ -153,6 +183,10 @@ func NewClientExt(rawRepoURL string, root string, creds Creds, insecure bool, en return client, nil } +var ( + gitClientTimeout = env.ParseDurationFromEnv("ARGOCD_GIT_REQUEST_TIMEOUT", 15*time.Second, 0, math.MaxInt64) +) + // Returns a HTTP client object suitable for go-git to use using the following // pattern: // - If insecure is true, always returns a client with certificate verification @@ -164,8 +198,8 @@ func NewClientExt(rawRepoURL string, root string, creds Creds, insecure bool, en func GetRepoHTTPClient(repoURL string, insecure bool, creds Creds, proxyURL string) *http.Client { // Default HTTP client var customHTTPClient = &http.Client{ - // 15 second timeout - Timeout: 15 * time.Second, + // 15 second timeout by default + Timeout: gitClientTimeout, // don't follow redirect CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -198,46 +232,30 @@ func GetRepoHTTPClient(repoURL string, insecure bool, creds Creds, proxyURL stri return &cert, nil } - + transport := &http.Transport{ + Proxy: proxyFunc, + TLSClientConfig: &tls.Config{ + GetClientCertificate: clientCertFunc, + }, + DisableKeepAlives: true, + } + customHTTPClient.Transport = transport if insecure { - customHTTPClient.Transport = &http.Transport{ - Proxy: proxyFunc, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - GetClientCertificate: clientCertFunc, - }, - DisableKeepAlives: true, - } - } else { - parsedURL, err := url.Parse(repoURL) - if err != nil { - return customHTTPClient - } - serverCertificatePem, err := certutil.GetCertificateForConnect(parsedURL.Host) - if err != nil { - return customHTTPClient - } else if len(serverCertificatePem) > 0 { - certPool := certutil.GetCertPoolFromPEMData(serverCertificatePem) - customHTTPClient.Transport = &http.Transport{ - Proxy: proxyFunc, - TLSClientConfig: &tls.Config{ - RootCAs: certPool, - GetClientCertificate: clientCertFunc, - }, - DisableKeepAlives: true, - } - } else { - // else no custom certificate stored. - customHTTPClient.Transport = &http.Transport{ - Proxy: proxyFunc, - TLSClientConfig: &tls.Config{ - GetClientCertificate: clientCertFunc, - }, - DisableKeepAlives: true, - } - } + transport.TLSClientConfig.InsecureSkipVerify = true + return customHTTPClient + } + parsedURL, err := url.Parse(repoURL) + if err != nil { + return customHTTPClient + } + serverCertificatePem, err := certutil.GetCertificateForConnect(parsedURL.Host) + if err != nil { + return customHTTPClient + } + if len(serverCertificatePem) > 0 { + certPool := certutil.GetCertPoolFromPEMData(serverCertificatePem) + transport.TLSClientConfig.RootCAs = certPool } - return customHTTPClient } @@ -268,6 +286,9 @@ func newAuth(repoURL string, creds Creds) (transport.AuthMethod, error) { return auth, nil case HTTPSCreds: auth := githttp.BasicAuth{Username: creds.username, Password: creds.password} + if auth.Username == "" { + auth.Username = "x-access-token" + } return &auth, nil case GitHubAppCreds: token, err := creds.getAccessToken() @@ -276,6 +297,18 @@ func newAuth(repoURL string, creds Creds) (transport.AuthMethod, error) { } auth := githttp.BasicAuth{Username: "x-access-token", Password: token} return &auth, nil + case GoogleCloudCreds: + username, err := creds.getUsername() + if err != nil { + return nil, fmt.Errorf("failed to get username from creds: %w", err) + } + token, err := creds.getAccessToken() + if err != nil { + return nil, fmt.Errorf("failed to get access token from creds: %w", err) + } + + auth := githttp.BasicAuth{Username: username, Password: token} + return &auth, nil } return nil, nil } @@ -294,7 +327,7 @@ func (m *nativeGitClient) Init() error { return err } log.Infof("Initializing %s to %s", m.repoURL, m.root) - _, err = executil.Run(exec.Command("rm", "-rf", m.root)) + err = os.RemoveAll(m.root) if err != nil { return fmt.Errorf("unable to clean repo at %s: %v", m.root, err) } @@ -318,6 +351,16 @@ func (m *nativeGitClient) IsLFSEnabled() bool { return m.enableLfs } +func (m *nativeGitClient) fetch(revision string) error { + var err error + if revision != "" { + err = m.runCredentialedCmd("fetch", "origin", revision, "--tags", "--force", "--prune") + } else { + err = m.runCredentialedCmd("fetch", "origin", "--tags", "--force", "--prune") + } + return err +} + // Fetch fetches latest updates from origin func (m *nativeGitClient) Fetch(revision string) error { if m.OnFetch != nil { @@ -325,34 +368,61 @@ func (m *nativeGitClient) Fetch(revision string) error { defer done() } - var err error - if revision != "" { - err = m.runCredentialedCmd("git", "fetch", "origin", revision, "--tags", "--force") - } else { - err = m.runCredentialedCmd("git", "fetch", "origin", "--tags", "--force") - } + err := m.fetch(revision) + // When we have LFS support enabled, check for large files and fetch them too. if err == nil && m.IsLFSEnabled() { largeFiles, err := m.LsLargeFiles() if err == nil && len(largeFiles) > 0 { - err = m.runCredentialedCmd("git", "lfs", "fetch", "--all") + err = m.runCredentialedCmd("lfs", "fetch", "--all") if err != nil { return err } } } + return err } // LsFiles lists the local working tree, including only files that are under source control -func (m *nativeGitClient) LsFiles(path string) ([]string, error) { - out, err := m.runCmd("ls-files", "--full-name", "-z", "--", path) - if err != nil { - return nil, err +func (m *nativeGitClient) LsFiles(path string, enableNewGitFileGlobbing bool) ([]string, error) { + if enableNewGitFileGlobbing { + // This is the new way with safer globbing + err := os.Chdir(m.root) + if err != nil { + return nil, err + } + all_files, err := doublestar.FilepathGlob(path) + if err != nil { + return nil, err + } + var files []string + for _, file := range all_files { + link, err := filepath.EvalSymlinks(file) + if err != nil { + return nil, err + } + absPath, err := filepath.Abs(link) + if err != nil { + return nil, err + } + if strings.HasPrefix(absPath, m.root) { + files = append(files, file) + } else { + log.Warnf("Absolute path for %s is outside of repository, removing it", file) + } + } + return files, nil + } else { + // This is the old and default way + out, err := m.runCmd("ls-files", "--full-name", "-z", "--", path) + if err != nil { + return nil, err + } + // remove last element, which is blank regardless of whether we're using nullbyte or newline + ss := strings.Split(out, "\000") + return ss[:len(ss)-1], nil } - // remove last element, which is blank regardless of whether we're using nullbyte or newline - ss := strings.Split(out, "\000") - return ss[:len(ss)-1], nil } // LsLargeFiles lists all files that have references to LFS storage @@ -365,8 +435,19 @@ func (m *nativeGitClient) LsLargeFiles() ([]string, error) { return ss, nil } +// Submodule embed other repositories into this repository +func (m *nativeGitClient) Submodule() error { + if err := m.runCredentialedCmd("submodule", "sync", "--recursive"); err != nil { + return err + } + if err := m.runCredentialedCmd("submodule", "update", "--init", "--recursive"); err != nil { + return err + } + return nil +} + // Checkout checkout specified revision -func (m *nativeGitClient) Checkout(revision string) error { +func (m *nativeGitClient) Checkout(revision string, submoduleEnabled bool) error { if revision == "" || revision == "HEAD" { revision = "origin/HEAD" } @@ -387,24 +468,54 @@ func (m *nativeGitClient) Checkout(revision string) error { } } if _, err := os.Stat(m.root + "/.gitmodules"); !os.IsNotExist(err) { - if submoduleEnabled := os.Getenv(common.EnvGitSubmoduleEnabled); submoduleEnabled != "false" { - if err := m.runCredentialedCmd("git", "submodule", "update", "--init", "--recursive"); err != nil { + if submoduleEnabled { + if err := m.Submodule(); err != nil { return err } } } - if _, err := m.runCmd("clean", "-fdx"); err != nil { + // NOTE + // The double “f” in the arguments is not a typo: the first “f” tells + // `git clean` to delete untracked files and directories, and the second “f” + // tells it to clean untractked nested Git repositories (for example a + // submodule which has since been removed). + if _, err := m.runCmd("clean", "-ffdx"); err != nil { return err } return nil } func (m *nativeGitClient) getRefs() ([]*plumbing.Reference, error) { + myLockUUID, err := uuid.NewRandom() + myLockId := "" + if err != nil { + log.Debug("Error generating git references cache lock id: ", err) + } else { + myLockId = myLockUUID.String() + } + // Prevent an additional get call to cache if we know our state isn't stale + needsUnlock := true if m.gitRefCache != nil && m.loadRefFromCache { var res []*plumbing.Reference - if m.gitRefCache.GetGitReferences(m.repoURL, &res) == nil { + foundLockId, err := m.gitRefCache.GetOrLockGitReferences(m.repoURL, myLockId, &res) + isLockOwner := myLockId == foundLockId + if !isLockOwner && err == nil { + // Valid value already in cache return res, nil + } else if !isLockOwner && err != nil { + // Error getting value from cache + log.Debugf("Error getting git references from cache: %v", err) + return nil, err } + // Defer a soft reset of the cache lock, if the value is set this call will be ignored + defer func() { + if needsUnlock { + err := m.gitRefCache.UnlockGitReferences(m.repoURL, myLockId) + if err != nil { + log.Debugf("Error unlocking git references from cache: %v", err) + } + } + }() } if m.OnLsRemote != nil { @@ -431,6 +542,9 @@ func (m *nativeGitClient) getRefs() ([]*plumbing.Reference, error) { if err == nil && m.gitRefCache != nil { if err := m.gitRefCache.SetGitReferences(m.repoURL, res); err != nil { log.Warnf("Failed to store git references to cache: %v", err) + } else { + // Since we successfully overwrote the lock with valid data, we don't need to unlock + needsUnlock = false } return res, nil } @@ -473,8 +587,19 @@ func (m *nativeGitClient) LsRefs() (*Refs, error) { // repository locally cloned. func (m *nativeGitClient) LsRemote(revision string) (res string, err error) { for attempt := 0; attempt < maxAttemptsCount; attempt++ { - if res, err = m.lsRemote(revision); err == nil { + res, err = m.lsRemote(revision) + if err == nil { return + } else if apierrors.IsInternalError(err) || apierrors.IsTimeout(err) || apierrors.IsServerTimeout(err) || + apierrors.IsTooManyRequests(err) || utilnet.IsProbableEOF(err) || utilnet.IsConnectionReset(err) { + // Formula: timeToWait = duration * factor^retry_number + // Note that timeToWait should equal to duration for the first retry attempt. + // When timeToWait is more than maxDuration retry should be performed at maxDuration. + timeToWait := float64(retryDuration) * (math.Pow(float64(factor), float64(attempt))) + if maxRetryDuration > 0 { + timeToWait = math.Min(float64(maxRetryDuration), timeToWait) + } + time.Sleep(time.Duration(timeToWait)) } } return @@ -575,42 +700,87 @@ func (m *nativeGitClient) VerifyCommitSignature(revision string) (string, error) return out, nil } +// IsAnnotatedTag returns true if the revision points to an annotated tag +func (m *nativeGitClient) IsAnnotatedTag(revision string) bool { + cmd := exec.Command("git", "describe", "--exact-match", revision) + out, err := m.runCmdOutput(cmd, runOpts{SkipErrorLogging: true}) + if out != "" && err == nil { + return true + } else { + return false + } +} + +// returns the meta-data for the commit +func (m *nativeGitClient) ChangedFiles(revision string, targetRevision string) ([]string, error) { + if revision == targetRevision { + return []string{}, nil + } + + if !IsCommitSHA(revision) || !IsCommitSHA(targetRevision) { + return []string{}, fmt.Errorf("invalid revision provided, must be SHA") + } + + out, err := m.runCmd("diff", "--name-only", fmt.Sprintf("%s..%s", revision, targetRevision)) + if err != nil { + return nil, fmt.Errorf("failed to diff %s..%s: %w", revision, targetRevision, err) + } + + if out == "" { + return []string{}, nil + } + + files := strings.Split(out, "\n") + return files, nil +} + // runWrapper runs a custom command with all the semantics of running the Git client func (m *nativeGitClient) runGnuPGWrapper(wrapper string, args ...string) (string, error) { cmd := exec.Command(wrapper, args...) cmd.Env = append(cmd.Env, fmt.Sprintf("GNUPGHOME=%s", common.GetGnuPGHomePath()), "LANG=C") - return m.runCmdOutput(cmd) + return m.runCmdOutput(cmd, runOpts{}) } // runCmd is a convenience function to run a command in a given directory and return its output func (m *nativeGitClient) runCmd(args ...string) (string, error) { cmd := exec.Command("git", args...) - return m.runCmdOutput(cmd) + return m.runCmdOutput(cmd, runOpts{}) } // runCredentialedCmd is a convenience function to run a git command with username/password credentials // nolint:unparam -func (m *nativeGitClient) runCredentialedCmd(command string, args ...string) error { - cmd := exec.Command(command, args...) +func (m *nativeGitClient) runCredentialedCmd(args ...string) error { closer, environ, err := m.creds.Environ() if err != nil { return err } defer func() { _ = closer.Close() }() + + // If a basic auth header is explicitly set, tell Git to send it to the + // server to force use of basic auth instead of negotiating the auth scheme + for _, e := range environ { + if strings.HasPrefix(e, fmt.Sprintf("%s=", forceBasicAuthHeaderEnv)) { + args = append([]string{"--config-env", fmt.Sprintf("http.extraHeader=%s", forceBasicAuthHeaderEnv)}, args...) + } + } + + cmd := exec.Command("git", args...) cmd.Env = append(cmd.Env, environ...) - _, err = m.runCmdOutput(cmd) + _, err = m.runCmdOutput(cmd, runOpts{}) return err } -func (m *nativeGitClient) runCmdOutput(cmd *exec.Cmd) (string, error) { +func (m *nativeGitClient) runCmdOutput(cmd *exec.Cmd, ropts runOpts) (string, error) { cmd.Dir = m.root - cmd.Env = append(cmd.Env, os.Environ()...) + cmd.Env = append(os.Environ(), cmd.Env...) // Set $HOME to nowhere, so we can be execute Git regardless of any external // authentication keys (e.g. in ~/.ssh) -- this is especially important for // running tests on local machines and/or CircleCI. cmd.Env = append(cmd.Env, "HOME=/dev/null") // Skip LFS for most Git operations except when explicitly requested cmd.Env = append(cmd.Env, "GIT_LFS_SKIP_SMUDGE=1") + // Disable Git terminal prompts in case we're running with a tty + cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=false") // For HTTPS repositories, we need to consider insecure repositories as well // as custom CA bundles from the cert database. @@ -631,8 +801,14 @@ func (m *nativeGitClient) runCmdOutput(cmd *exec.Cmd) (string, error) { } } } - cmd.Env = proxy.UpsertEnv(cmd, m.proxy) - - return executil.Run(cmd) + opts := executil.ExecRunOpts{ + TimeoutBehavior: argoexec.TimeoutBehavior{ + Signal: syscall.SIGTERM, + ShouldWait: true, + }, + SkipErrorLogging: ropts.SkipErrorLogging, + CaptureStderr: ropts.CaptureStderr, + } + return executil.RunWithExecRunOpts(cmd, opts) } diff --git a/ext/git/client_test.go b/ext/git/client_test.go new file mode 100644 index 0000000..b9897de --- /dev/null +++ b/ext/git/client_test.go @@ -0,0 +1,263 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(workingDir string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = workingDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func _createEmptyGitRepo() (string, error) { + tempDir, err := os.MkdirTemp("", "") + if err != nil { + return tempDir, err + } + + err = runCmd(tempDir, "git", "init") + if err != nil { + return tempDir, err + } + + err = runCmd(tempDir, "git", "commit", "-m", "Initial commit", "--allow-empty") + return tempDir, err +} + +func Test_nativeGitClient_Fetch(t *testing.T) { + tempDir, err := _createEmptyGitRepo() + require.NoError(t, err) + + client, err := NewClient(fmt.Sprintf("file://%s", tempDir), NopCreds{}, true, false, "") + require.NoError(t, err) + + err = client.Init() + require.NoError(t, err) + + err = client.Fetch("") + assert.NoError(t, err) +} + +func Test_nativeGitClient_Fetch_Prune(t *testing.T) { + tempDir, err := _createEmptyGitRepo() + require.NoError(t, err) + + client, err := NewClient(fmt.Sprintf("file://%s", tempDir), NopCreds{}, true, false, "") + require.NoError(t, err) + + err = client.Init() + require.NoError(t, err) + + err = runCmd(tempDir, "git", "branch", "test/foo") + require.NoError(t, err) + + err = client.Fetch("") + assert.NoError(t, err) + + err = runCmd(tempDir, "git", "branch", "-d", "test/foo") + require.NoError(t, err) + err = runCmd(tempDir, "git", "branch", "test/foo/bar") + require.NoError(t, err) + + err = client.Fetch("") + assert.NoError(t, err) +} + +func Test_IsAnnotatedTag(t *testing.T) { + tempDir := t.TempDir() + client, err := NewClient(fmt.Sprintf("file://%s", tempDir), NopCreds{}, true, false, "") + require.NoError(t, err) + + err = client.Init() + require.NoError(t, err) + + p := path.Join(client.Root(), "README") + f, err := os.Create(p) + require.NoError(t, err) + _, err = f.WriteString("Hello.") + require.NoError(t, err) + err = f.Close() + require.NoError(t, err) + + err = runCmd(client.Root(), "git", "add", "README") + require.NoError(t, err) + + err = runCmd(client.Root(), "git", "commit", "-m", "Initial commit", "-a") + require.NoError(t, err) + + atag := client.IsAnnotatedTag("master") + assert.False(t, atag) + + err = runCmd(client.Root(), "git", "tag", "some-tag", "-a", "-m", "Create annotated tag") + require.NoError(t, err) + atag = client.IsAnnotatedTag("some-tag") + assert.True(t, atag) + + // Tag effectually points to HEAD, so it's considered the same + atag = client.IsAnnotatedTag("HEAD") + assert.True(t, atag) + + err = runCmd(client.Root(), "git", "rm", "README") + assert.NoError(t, err) + err = runCmd(client.Root(), "git", "commit", "-m", "remove README", "-a") + assert.NoError(t, err) + + // We moved on, so tag doesn't point to HEAD anymore + atag = client.IsAnnotatedTag("HEAD") + assert.False(t, atag) +} + +func Test_ChangedFiles(t *testing.T) { + tempDir := t.TempDir() + + client, err := NewClientExt(fmt.Sprintf("file://%s", tempDir), tempDir, NopCreds{}, true, false, "") + require.NoError(t, err) + + err = client.Init() + require.NoError(t, err) + + err = runCmd(client.Root(), "git", "commit", "-m", "Initial commit", "--allow-empty") + require.NoError(t, err) + + // Create a tag to have a second ref + err = runCmd(client.Root(), "git", "tag", "some-tag") + require.NoError(t, err) + + p := path.Join(client.Root(), "README") + f, err := os.Create(p) + require.NoError(t, err) + _, err = f.WriteString("Hello.") + require.NoError(t, err) + err = f.Close() + require.NoError(t, err) + + err = runCmd(client.Root(), "git", "add", "README") + require.NoError(t, err) + + err = runCmd(client.Root(), "git", "commit", "-m", "Changes", "-a") + require.NoError(t, err) + + previousSHA, err := client.LsRemote("some-tag") + require.NoError(t, err) + + commitSHA, err := client.LsRemote("HEAD") + require.NoError(t, err) + + // Invalid commits, error + _, err = client.ChangedFiles("0000000000000000000000000000000000000000", "1111111111111111111111111111111111111111") + require.Error(t, err) + + // Not SHAs, error + _, err = client.ChangedFiles(previousSHA, "HEAD") + require.Error(t, err) + + // Same commit, no changes + changedFiles, err := client.ChangedFiles(commitSHA, commitSHA) + require.NoError(t, err) + assert.ElementsMatch(t, []string{}, changedFiles) + + // Different ref, with changes + changedFiles, err = client.ChangedFiles(previousSHA, commitSHA) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"README"}, changedFiles) +} + +func Test_nativeGitClient_Submodule(t *testing.T) { + tempDir, err := os.MkdirTemp("", "") + require.NoError(t, err) + + foo := filepath.Join(tempDir, "foo") + err = os.Mkdir(foo, 0755) + require.NoError(t, err) + + err = runCmd(foo, "git", "init") + require.NoError(t, err) + + bar := filepath.Join(tempDir, "bar") + err = os.Mkdir(bar, 0755) + require.NoError(t, err) + + err = runCmd(bar, "git", "init") + require.NoError(t, err) + + err = runCmd(bar, "git", "commit", "-m", "Initial commit", "--allow-empty") + require.NoError(t, err) + + // Embed repository bar into repository foo + t.Setenv("GIT_ALLOW_PROTOCOL", "file") + err = runCmd(foo, "git", "submodule", "add", bar) + require.NoError(t, err) + + err = runCmd(foo, "git", "commit", "-m", "Initial commit") + require.NoError(t, err) + + tempDir, err = os.MkdirTemp("", "") + require.NoError(t, err) + + // Clone foo + err = runCmd(tempDir, "git", "clone", foo) + require.NoError(t, err) + + client, err := NewClient(fmt.Sprintf("file://%s", foo), NopCreds{}, true, false, "") + require.NoError(t, err) + + err = client.Init() + require.NoError(t, err) + + err = client.Fetch("") + assert.NoError(t, err) + + commitSHA, err := client.LsRemote("HEAD") + assert.NoError(t, err) + + // Call Checkout() with submoduleEnabled=false. + err = client.Checkout(commitSHA, false) + assert.NoError(t, err) + + // Check if submodule url does not exist in .git/config + err = runCmd(client.Root(), "git", "config", "submodule.bar.url") + assert.Error(t, err) + + // Call Submodule() via Checkout() with submoduleEnabled=true. + err = client.Checkout(commitSHA, true) + assert.NoError(t, err) + + // Check if the .gitmodule URL is reflected in .git/config + cmd := exec.Command("git", "config", "submodule.bar.url") + cmd.Dir = client.Root() + result, err := cmd.Output() + assert.NoError(t, err) + assert.Equal(t, bar+"\n", string(result)) + + // Change URL of submodule bar + err = runCmd(client.Root(), "git", "config", "--file=.gitmodules", "submodule.bar.url", bar+"baz") + require.NoError(t, err) + + // Call Submodule() + err = client.Submodule() + assert.NoError(t, err) + + // Check if the URL change in .gitmodule is reflected in .git/config + cmd = exec.Command("git", "config", "submodule.bar.url") + cmd.Dir = client.Root() + result, err = cmd.Output() + assert.NoError(t, err) + assert.Equal(t, bar+"baz\n", string(result)) +} + +func TestNewClient_invalidSSHURL(t *testing.T) { + client, err := NewClient("ssh://bitbucket.org:org/repo", NopCreds{}, false, false, "") + assert.Nil(t, client) + assert.ErrorIs(t, err, ErrInvalidRepoURL) +} diff --git a/ext/git/creds.go b/ext/git/creds.go index c8f6493..1869844 100644 --- a/ext/git/creds.go +++ b/ext/git/creds.go @@ -3,27 +3,45 @@ package git import ( "context" "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" "fmt" "io" + "net/url" "os" "strconv" "strings" "time" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + gocache "github.com/patrickmn/go-cache" argoio "github.com/argoproj/gitops-engine/pkg/utils/io" - "github.com/bradleyfalzon/ghinstallation" + "github.com/argoproj/gitops-engine/pkg/utils/text" + "github.com/bradleyfalzon/ghinstallation/v2" log "github.com/sirupsen/logrus" "github.com/argoproj/argo-cd/v2/common" - certutil "github.com/argoproj/argo-cd/v2/util/cert" + argoioutils "github.com/argoproj/argo-cd/v2/util/io" ) -// In memory cache for storing github APP api token credentials var ( + // In memory cache for storing github APP api token credentials githubAppTokenCache *gocache.Cache + // In memory cache for storing oauth2.TokenSource used to generate Google Cloud OAuth tokens + googleCloudTokenSource *gocache.Cache +) + +const ( + // ASKPASS_NONCE_ENV is the environment variable that is used to pass the nonce to the askpass script + ASKPASS_NONCE_ENV = "ARGOCD_GIT_ASKPASS_NONCE" + // githubAccessTokenUsername is a username that is used to with the github access token + githubAccessTokenUsername = "x-access-token" + forceBasicAuthHeaderEnv = "ARGOCD_GIT_AUTH_HEADER" ) func init() { @@ -35,12 +53,38 @@ func init() { } githubAppTokenCache = gocache.New(githubAppCredsExp, 1*time.Minute) + // oauth2.TokenSource handles fetching new Tokens once they are expired. The oauth2.TokenSource itself does not expire. + googleCloudTokenSource = gocache.New(gocache.NoExpiration, 0) +} + +type NoopCredsStore struct { +} + +func (d NoopCredsStore) Add(username string, password string) string { + return "" +} + +func (d NoopCredsStore) Remove(id string) { +} + +type CredsStore interface { + Add(username string, password string) string + Remove(id string) } type Creds interface { Environ() (io.Closer, []string, error) } +func getGitAskPassEnv(id string) []string { + return []string{ + fmt.Sprintf("GIT_ASKPASS=%s", "argocd"), + fmt.Sprintf("%s=%s", ASKPASS_NONCE_ENV, id), + "GIT_TERMINAL_PROMPT=0", + "ARGOCD_BINARY_NAME=argocd-git-ask-pass", + } +} + // nop implementation type NopCloser struct { } @@ -83,9 +127,13 @@ type HTTPSCreds struct { clientCertKey string // HTTP/HTTPS proxy used to access repository proxy string + // temporal credentials store + store CredsStore + // whether to force usage of basic auth + forceBasicAuth bool } -func NewHTTPSCreds(username string, password string, clientCertData string, clientCertKey string, insecure bool, proxy string) GenericHTTPSCreds { +func NewHTTPSCreds(username string, password string, clientCertData string, clientCertKey string, insecure bool, proxy string, store CredsStore, forceBasicAuth bool) GenericHTTPSCreds { return HTTPSCreds{ username, password, @@ -93,13 +141,23 @@ func NewHTTPSCreds(username string, password string, clientCertData string, clie clientCertData, clientCertKey, proxy, + store, + forceBasicAuth, } } +func (c HTTPSCreds) BasicAuthHeader() string { + h := "Authorization: Basic " + t := c.username + ":" + c.password + h += base64.StdEncoding.EncodeToString([]byte(t)) + return h +} + // Get additional required environment variables for executing git client to // access specific repository via HTTPS. func (c HTTPSCreds) Environ() (io.Closer, []string, error) { - env := []string{fmt.Sprintf("GIT_ASKPASS=%s", "git-ask-pass.sh"), fmt.Sprintf("GIT_USERNAME=%s", c.username), fmt.Sprintf("GIT_PASSWORD=%s", c.password)} + var env []string + httpCloser := authFilePaths(make([]string, 0)) // GIT_SSL_NO_VERIFY is used to tell git not to validate the server's cert at @@ -151,9 +209,19 @@ func (c HTTPSCreds) Environ() (io.Closer, []string, error) { } // GIT_SSL_KEY is the full path to a client certificate's key to be used env = append(env, fmt.Sprintf("GIT_SSL_KEY=%s", keyFile.Name())) - } - return httpCloser, env, nil + // If at least password is set, we will set ARGOCD_BASIC_AUTH_HEADER to + // hold the HTTP authorization header, so auth mechanism negotiation is + // skipped. This is insecure, but some environments may need it. + if c.password != "" && c.forceBasicAuth { + env = append(env, fmt.Sprintf("%s=%s", forceBasicAuthHeaderEnv, c.BasicAuthHeader())) + } + nonce := c.store.Add(text.FirstNonEmpty(c.username, githubAccessTokenUsername), c.password) + env = append(env, getGitAskPassEnv(nonce)...) + return argoioutils.NewCloser(func() error { + c.store.Remove(nonce) + return httpCloser.Close() + }), env, nil } func (g HTTPSCreds) HasClientCert() bool { @@ -173,10 +241,12 @@ type SSHCreds struct { sshPrivateKey string caPath string insecure bool + store CredsStore + proxy string } -func NewSSHCreds(sshPrivateKey string, caPath string, insecureIgnoreHostKey bool) SSHCreds { - return SSHCreds{sshPrivateKey, caPath, insecureIgnoreHostKey} +func NewSSHCreds(sshPrivateKey string, caPath string, insecureIgnoreHostKey bool, store CredsStore, proxy string) SSHCreds { + return SSHCreds{sshPrivateKey, caPath, insecureIgnoreHostKey, store, proxy} } type sshPrivateKeyFile string @@ -207,7 +277,14 @@ func (c SSHCreds) Environ() (io.Closer, []string, error) { if err != nil { return nil, nil, err } - defer file.Close() + defer func() { + if err = file.Close(); err != nil { + log.WithFields(log.Fields{ + common.SecurityField: common.SecurityMedium, + common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor, + }).Errorf("error closing file %q: %v", file.Name(), err) + } + }() _, err = file.WriteString(c.sshPrivateKey + "\n") if err != nil { @@ -228,7 +305,25 @@ func (c SSHCreds) Environ() (io.Closer, []string, error) { knownHostsFile := certutil.GetSSHKnownHostsDataPath() args = append(args, "-o", "StrictHostKeyChecking=yes", "-o", fmt.Sprintf("UserKnownHostsFile=%s", knownHostsFile)) } + // Handle SSH socks5 proxy settings + proxyEnv := []string{} + if c.proxy != "" { + parsedProxyURL, err := url.Parse(c.proxy) + if err != nil { + return nil, nil, fmt.Errorf("failed to set environment variables related to socks5 proxy, could not parse proxy URL '%s': %w", c.proxy, err) + } + args = append(args, "-o", fmt.Sprintf("ProxyCommand='connect-proxy -S %s:%s -5 %%h %%p'", + parsedProxyURL.Hostname(), + parsedProxyURL.Port())) + if parsedProxyURL.User != nil { + proxyEnv = append(proxyEnv, fmt.Sprintf("SOCKS5_USER=%s", parsedProxyURL.User.Username())) + if socks5_passwd, isPasswdSet := parsedProxyURL.User.Password(); isPasswdSet { + proxyEnv = append(proxyEnv, fmt.Sprintf("SOCKS5_PASSWD=%s", socks5_passwd)) + } + } + } env = append(env, []string{fmt.Sprintf("GIT_SSH_COMMAND=%s", strings.Join(args, " "))}...) + env = append(env, proxyEnv...) return sshPrivateKeyFile(file.Name()), env, nil } @@ -243,11 +338,12 @@ type GitHubAppCreds struct { clientCertKey string insecure bool proxy string + store CredsStore } // NewGitHubAppCreds provide github app credentials -func NewGitHubAppCreds(appID int64, appInstallId int64, privateKey string, baseURL string, repoURL string, clientCertData string, clientCertKey string, insecure bool) GenericHTTPSCreds { - return GitHubAppCreds{appID: appID, appInstallId: appInstallId, privateKey: privateKey, baseURL: baseURL, repoURL: repoURL, clientCertData: clientCertData, clientCertKey: clientCertKey, insecure: insecure} +func NewGitHubAppCreds(appID int64, appInstallId int64, privateKey string, baseURL string, repoURL string, clientCertData string, clientCertKey string, insecure bool, proxy string, store CredsStore) GenericHTTPSCreds { + return GitHubAppCreds{appID: appID, appInstallId: appInstallId, privateKey: privateKey, baseURL: baseURL, repoURL: repoURL, clientCertData: clientCertData, clientCertKey: clientCertKey, insecure: insecure, proxy: proxy, store: store} } func (g GitHubAppCreds) Environ() (io.Closer, []string, error) { @@ -255,8 +351,7 @@ func (g GitHubAppCreds) Environ() (io.Closer, []string, error) { if err != nil { return NopCloser{}, nil, err } - - env := []string{fmt.Sprintf("GIT_ASKPASS=%s", "git-ask-pass.sh"), "GIT_USERNAME=x-access-token", fmt.Sprintf("GIT_PASSWORD=%s", token)} + var env []string httpCloser := authFilePaths(make([]string, 0)) // GIT_SSL_NO_VERIFY is used to tell git not to validate the server's cert at @@ -310,7 +405,12 @@ func (g GitHubAppCreds) Environ() (io.Closer, []string, error) { env = append(env, fmt.Sprintf("GIT_SSL_KEY=%s", keyFile.Name())) } - return httpCloser, env, nil + nonce := g.store.Add(githubAccessTokenUsername, token) + env = append(env, getGitAskPassEnv(nonce)...) + return argoioutils.NewCloser(func() error { + g.store.Remove(nonce) + return httpCloser.Close() + }), env, nil } // getAccessToken fetches GitHub token using the app id, install id, and private key. @@ -372,3 +472,98 @@ func (g GitHubAppCreds) GetClientCertData() string { func (g GitHubAppCreds) GetClientCertKey() string { return g.clientCertKey } + +// GoogleCloudCreds to authenticate to Google Cloud Source repositories +type GoogleCloudCreds struct { + creds *google.Credentials + store CredsStore +} + +func NewGoogleCloudCreds(jsonData string, store CredsStore) GoogleCloudCreds { + creds, err := google.CredentialsFromJSON(context.Background(), []byte(jsonData), "https://www.googleapis.com/auth/cloud-platform") + if err != nil { + // Invalid JSON + log.Errorf("Failed reading credentials from JSON: %+v", err) + } + return GoogleCloudCreds{creds, store} +} + +func (c GoogleCloudCreds) Environ() (io.Closer, []string, error) { + username, err := c.getUsername() + if err != nil { + return NopCloser{}, nil, fmt.Errorf("failed to get username from creds: %w", err) + } + token, err := c.getAccessToken() + if err != nil { + return NopCloser{}, nil, fmt.Errorf("failed to get access token from creds: %w", err) + } + + nonce := c.store.Add(username, token) + env := getGitAskPassEnv(nonce) + + return argoioutils.NewCloser(func() error { + c.store.Remove(nonce) + return NopCloser{}.Close() + }), env, nil +} + +func (c GoogleCloudCreds) getUsername() (string, error) { + type googleCredentialsFile struct { + Type string `json:"type"` + + // Service Account fields + ClientEmail string `json:"client_email"` + PrivateKeyID string `json:"private_key_id"` + PrivateKey string `json:"private_key"` + AuthURL string `json:"auth_uri"` + TokenURL string `json:"token_uri"` + ProjectID string `json:"project_id"` + } + + if c.creds == nil { + return "", errors.New("credentials for Google Cloud Source repositories are invalid") + } + + var f googleCredentialsFile + if err := json.Unmarshal(c.creds.JSON, &f); err != nil { + return "", fmt.Errorf("failed to unmarshal Google Cloud credentials: %w", err) + } + return f.ClientEmail, nil +} + +func (c GoogleCloudCreds) getAccessToken() (string, error) { + if c.creds == nil { + return "", errors.New("credentials for Google Cloud Source repositories are invalid") + } + + // Compute hash of creds for lookup in cache + h := sha256.New() + _, err := h.Write(c.creds.JSON) + if err != nil { + return "", err + } + key := fmt.Sprintf("%x", h.Sum(nil)) + + t, found := googleCloudTokenSource.Get(key) + if found { + ts := t.(*oauth2.TokenSource) + token, err := (*ts).Token() + if err != nil { + return "", fmt.Errorf("failed to get token from Google Cloud token source: %w", err) + } + return token.AccessToken, nil + } + + ts := c.creds.TokenSource + + // Add TokenSource to cache + // As TokenSource handles refreshing tokens once they expire itself, TokenSource itself can be reused. Hence, no expiration. + googleCloudTokenSource.Set(key, &ts, gocache.NoExpiration) + + token, err := ts.Token() + if err != nil { + return "", fmt.Errorf("failed to get get SHA256 hash for Google Cloud credentials: %w", err) + } + + return token.AccessToken, nil +} diff --git a/ext/git/creds_test.go b/ext/git/creds_test.go new file mode 100644 index 0000000..23a705e --- /dev/null +++ b/ext/git/creds_test.go @@ -0,0 +1,369 @@ +package git + +import ( + "encoding/base64" + "fmt" + "os" + "path" + "regexp" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/argoproj/argo-cd/v2/util/cert" + "github.com/argoproj/argo-cd/v2/util/io" +) + +type cred struct { + username string + password string +} + +type memoryCredsStore struct { + creds map[string]cred +} + +func (s *memoryCredsStore) Add(username string, password string) string { + id := uuid.New().String() + s.creds[id] = cred{ + username: username, + password: password, + } + return id +} + +func (s *memoryCredsStore) Remove(id string) { + delete(s.creds, id) +} + +func TestHTTPSCreds_Environ_no_cert_cleanup(t *testing.T) { + store := &memoryCredsStore{creds: make(map[string]cred)} + creds := NewHTTPSCreds("", "", "", "", true, "", store, false) + closer, env, err := creds.Environ() + require.NoError(t, err) + var nonce string + for _, envVar := range env { + if strings.HasPrefix(envVar, ASKPASS_NONCE_ENV) { + nonce = envVar[len(ASKPASS_NONCE_ENV)+1:] + break + } + } + assert.Contains(t, store.creds, nonce) + io.Close(closer) + assert.NotContains(t, store.creds, nonce) +} + +func TestHTTPSCreds_Environ_insecure_true(t *testing.T) { + creds := NewHTTPSCreds("", "", "", "", true, "", &NoopCredsStore{}, false) + closer, env, err := creds.Environ() + t.Cleanup(func() { + io.Close(closer) + }) + require.NoError(t, err) + found := false + for _, envVar := range env { + if envVar == "GIT_SSL_NO_VERIFY=true" { + found = true + break + } + } + assert.True(t, found) +} + +func TestHTTPSCreds_Environ_insecure_false(t *testing.T) { + creds := NewHTTPSCreds("", "", "", "", false, "", &NoopCredsStore{}, false) + closer, env, err := creds.Environ() + t.Cleanup(func() { + io.Close(closer) + }) + require.NoError(t, err) + found := false + for _, envVar := range env { + if envVar == "GIT_SSL_NO_VERIFY=true" { + found = true + break + } + } + assert.False(t, found) +} + +func TestHTTPSCreds_Environ_forceBasicAuth(t *testing.T) { + t.Run("Enabled and credentials set", func(t *testing.T) { + store := &memoryCredsStore{creds: make(map[string]cred)} + creds := NewHTTPSCreds("username", "password", "", "", false, "", store, true) + closer, env, err := creds.Environ() + require.NoError(t, err) + defer closer.Close() + var header string + for _, envVar := range env { + if strings.HasPrefix(envVar, fmt.Sprintf("%s=", forceBasicAuthHeaderEnv)) { + header = envVar[len(forceBasicAuthHeaderEnv)+1:] + } + if header != "" { + break + } + } + b64enc := base64.StdEncoding.EncodeToString([]byte("username:password")) + assert.Equal(t, "Authorization: Basic "+b64enc, header) + }) + t.Run("Enabled but credentials not set", func(t *testing.T) { + store := &memoryCredsStore{creds: make(map[string]cred)} + creds := NewHTTPSCreds("", "", "", "", false, "", store, true) + closer, env, err := creds.Environ() + require.NoError(t, err) + defer closer.Close() + var header string + for _, envVar := range env { + if strings.HasPrefix(envVar, fmt.Sprintf("%s=", forceBasicAuthHeaderEnv)) { + header = envVar[len(forceBasicAuthHeaderEnv)+1:] + } + if header != "" { + break + } + } + assert.Empty(t, header) + }) + t.Run("Disabled with credentials set", func(t *testing.T) { + store := &memoryCredsStore{creds: make(map[string]cred)} + creds := NewHTTPSCreds("username", "password", "", "", false, "", store, false) + closer, env, err := creds.Environ() + require.NoError(t, err) + defer closer.Close() + var header string + for _, envVar := range env { + if strings.HasPrefix(envVar, fmt.Sprintf("%s=", forceBasicAuthHeaderEnv)) { + header = envVar[len(forceBasicAuthHeaderEnv)+1:] + } + if header != "" { + break + } + } + assert.Empty(t, header) + }) + + t.Run("Disabled with credentials not set", func(t *testing.T) { + store := &memoryCredsStore{creds: make(map[string]cred)} + creds := NewHTTPSCreds("", "", "", "", false, "", store, false) + closer, env, err := creds.Environ() + require.NoError(t, err) + defer closer.Close() + var header string + for _, envVar := range env { + if strings.HasPrefix(envVar, fmt.Sprintf("%s=", forceBasicAuthHeaderEnv)) { + header = envVar[len(forceBasicAuthHeaderEnv)+1:] + } + if header != "" { + break + } + } + assert.Empty(t, header) + }) +} + +func TestHTTPSCreds_Environ_clientCert(t *testing.T) { + store := &memoryCredsStore{creds: make(map[string]cred)} + creds := NewHTTPSCreds("", "", "clientCertData", "clientCertKey", false, "", store, false) + closer, env, err := creds.Environ() + require.NoError(t, err) + var cert, key string + for _, envVar := range env { + if strings.HasPrefix(envVar, "GIT_SSL_CERT=") { + cert = envVar[13:] + } else if strings.HasPrefix(envVar, "GIT_SSL_KEY=") { + key = envVar[12:] + } + if cert != "" && key != "" { + break + } + } + assert.NotEmpty(t, cert) + assert.NotEmpty(t, key) + + certBytes, err := os.ReadFile(cert) + assert.NoError(t, err) + assert.Equal(t, "clientCertData", string(certBytes)) + keyBytes, err := os.ReadFile(key) + assert.Equal(t, "clientCertKey", string(keyBytes)) + assert.NoError(t, err) + + io.Close(closer) + + _, err = os.Stat(cert) + assert.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(key) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func Test_SSHCreds_Environ(t *testing.T) { + for _, insecureIgnoreHostKey := range []bool{false, true} { + tempDir := t.TempDir() + caFile := path.Join(tempDir, "caFile") + err := os.WriteFile(caFile, []byte(""), os.FileMode(0600)) + require.NoError(t, err) + creds := NewSSHCreds("sshPrivateKey", caFile, insecureIgnoreHostKey, &NoopCredsStore{}, "") + closer, env, err := creds.Environ() + require.NoError(t, err) + require.Len(t, env, 2) + + assert.Equal(t, fmt.Sprintf("GIT_SSL_CAINFO=%s/caFile", tempDir), env[0], "CAINFO env var must be set") + + assert.True(t, strings.HasPrefix(env[1], "GIT_SSH_COMMAND=")) + + if insecureIgnoreHostKey { + assert.Contains(t, env[1], "-o StrictHostKeyChecking=no") + assert.Contains(t, env[1], "-o UserKnownHostsFile=/dev/null") + } else { + assert.Contains(t, env[1], "-o StrictHostKeyChecking=yes") + hostsPath := cert.GetSSHKnownHostsDataPath() + assert.Contains(t, env[1], fmt.Sprintf("-o UserKnownHostsFile=%s", hostsPath)) + } + + envRegex := regexp.MustCompile("-i ([^ ]+)") + assert.Regexp(t, envRegex, env[1]) + privateKeyFile := envRegex.FindStringSubmatch(env[1])[1] + assert.FileExists(t, privateKeyFile) + io.Close(closer) + assert.NoFileExists(t, privateKeyFile) + } +} + +func Test_SSHCreds_Environ_WithProxy(t *testing.T) { + for _, insecureIgnoreHostKey := range []bool{false, true} { + tempDir := t.TempDir() + caFile := path.Join(tempDir, "caFile") + err := os.WriteFile(caFile, []byte(""), os.FileMode(0600)) + require.NoError(t, err) + creds := NewSSHCreds("sshPrivateKey", caFile, insecureIgnoreHostKey, &NoopCredsStore{}, "socks5://127.0.0.1:1080") + closer, env, err := creds.Environ() + require.NoError(t, err) + require.Len(t, env, 2) + + assert.Equal(t, fmt.Sprintf("GIT_SSL_CAINFO=%s/caFile", tempDir), env[0], "CAINFO env var must be set") + + assert.True(t, strings.HasPrefix(env[1], "GIT_SSH_COMMAND=")) + + if insecureIgnoreHostKey { + assert.Contains(t, env[1], "-o StrictHostKeyChecking=no") + assert.Contains(t, env[1], "-o UserKnownHostsFile=/dev/null") + } else { + assert.Contains(t, env[1], "-o StrictHostKeyChecking=yes") + hostsPath := cert.GetSSHKnownHostsDataPath() + assert.Contains(t, env[1], fmt.Sprintf("-o UserKnownHostsFile=%s", hostsPath)) + } + assert.Contains(t, env[1], "-o ProxyCommand='connect-proxy -S 127.0.0.1:1080 -5 %h %p'") + + envRegex := regexp.MustCompile("-i ([^ ]+)") + assert.Regexp(t, envRegex, env[1]) + privateKeyFile := envRegex.FindStringSubmatch(env[1])[1] + assert.FileExists(t, privateKeyFile) + io.Close(closer) + assert.NoFileExists(t, privateKeyFile) + } +} + +func Test_SSHCreds_Environ_WithProxyUserNamePassword(t *testing.T) { + for _, insecureIgnoreHostKey := range []bool{false, true} { + tempDir := t.TempDir() + caFile := path.Join(tempDir, "caFile") + err := os.WriteFile(caFile, []byte(""), os.FileMode(0600)) + require.NoError(t, err) + creds := NewSSHCreds("sshPrivateKey", caFile, insecureIgnoreHostKey, &NoopCredsStore{}, "socks5://user:password@127.0.0.1:1080") + closer, env, err := creds.Environ() + require.NoError(t, err) + require.Len(t, env, 4) + + assert.Equal(t, fmt.Sprintf("GIT_SSL_CAINFO=%s/caFile", tempDir), env[0], "CAINFO env var must be set") + + assert.True(t, strings.HasPrefix(env[1], "GIT_SSH_COMMAND=")) + assert.Equal(t, "SOCKS5_USER=user", env[2], "SOCKS5 user env var must be set") + assert.Equal(t, "SOCKS5_PASSWD=password", env[3], "SOCKS5 password env var must be set") + + if insecureIgnoreHostKey { + assert.Contains(t, env[1], "-o StrictHostKeyChecking=no") + assert.Contains(t, env[1], "-o UserKnownHostsFile=/dev/null") + } else { + assert.Contains(t, env[1], "-o StrictHostKeyChecking=yes") + hostsPath := cert.GetSSHKnownHostsDataPath() + assert.Contains(t, env[1], fmt.Sprintf("-o UserKnownHostsFile=%s", hostsPath)) + } + assert.Contains(t, env[1], "-o ProxyCommand='connect-proxy -S 127.0.0.1:1080 -5 %h %p'") + + envRegex := regexp.MustCompile("-i ([^ ]+)") + assert.Regexp(t, envRegex, env[1]) + privateKeyFile := envRegex.FindStringSubmatch(env[1])[1] + assert.FileExists(t, privateKeyFile) + io.Close(closer) + assert.NoFileExists(t, privateKeyFile) + } +} + +const gcpServiceAccountKeyJSON = `{ + "type": "service_account", + "project_id": "my-google-project", + "private_key_id": "REDACTED", + "private_key": "-----BEGIN PRIVATE KEY-----\nREDACTED\n-----END PRIVATE KEY-----\n", + "client_email": "argocd-service-account@my-google-project.iam.gserviceaccount.com", + "client_id": "REDACTED", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/argocd-service-account%40my-google-project.iam.gserviceaccount.com" +}` + +const invalidJSON = `{ + "type": "service_account", + "project_id": "my-google-project", +` + +func TestNewGoogleCloudCreds(t *testing.T) { + store := &memoryCredsStore{creds: make(map[string]cred)} + googleCloudCreds := NewGoogleCloudCreds(gcpServiceAccountKeyJSON, store) + assert.NotNil(t, googleCloudCreds) +} + +func TestNewGoogleCloudCreds_invalidJSON(t *testing.T) { + store := &memoryCredsStore{creds: make(map[string]cred)} + googleCloudCreds := NewGoogleCloudCreds(invalidJSON, store) + assert.Nil(t, googleCloudCreds.creds) + + token, err := googleCloudCreds.getAccessToken() + assert.Equal(t, "", token) + assert.NotNil(t, err) + + username, err := googleCloudCreds.getUsername() + assert.Equal(t, "", username) + assert.NotNil(t, err) + + closer, envStringSlice, err := googleCloudCreds.Environ() + assert.Equal(t, NopCloser{}, closer) + assert.Equal(t, []string(nil), envStringSlice) + assert.NotNil(t, err) +} + +func TestGoogleCloudCreds_Environ_cleanup(t *testing.T) { + store := &memoryCredsStore{creds: make(map[string]cred)} + staticToken := &oauth2.Token{AccessToken: "token"} + googleCloudCreds := GoogleCloudCreds{&google.Credentials{ + ProjectID: "my-google-project", + TokenSource: oauth2.StaticTokenSource(staticToken), + JSON: []byte(gcpServiceAccountKeyJSON), + }, store} + + closer, env, err := googleCloudCreds.Environ() + assert.NoError(t, err) + var nonce string + for _, envVar := range env { + if strings.HasPrefix(envVar, ASKPASS_NONCE_ENV) { + nonce = envVar[len(ASKPASS_NONCE_ENV)+1:] + break + } + } + assert.Contains(t, store.creds, nonce) + io.Close(closer) + assert.NotContains(t, store.creds, nonce) +} diff --git a/ext/git/git.go b/ext/git/git.go index b925789..d5a8652 100644 --- a/ext/git/git.go +++ b/ext/git/git.go @@ -14,14 +14,6 @@ func ensurePrefix(s, prefix string) string { return s } -// removeSuffix idempotently removes a given suffix -func removeSuffix(s, suffix string) string { - if strings.HasSuffix(s, suffix) { - return s[0 : len(s)-len(suffix)] - } - return s -} - var ( commitSHARegex = regexp.MustCompile("^[0-9A-Fa-f]{40}$") sshURLRegex = regexp.MustCompile("^(ssh://)?([^/:]*?)@[^@]+$") @@ -62,7 +54,7 @@ func NormalizeGitURL(repo string) string { repo = ensurePrefix(repo, "ssh://") } } - repo = removeSuffix(repo, ".git") + repo = strings.TrimSuffix(repo, ".git") repoURL, err := url.Parse(repo) if err != nil { return "" diff --git a/ext/git/git_test.go b/ext/git/git_test.go index de04454..0eebe35 100644 --- a/ext/git/git_test.go +++ b/ext/git/git_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/argoproj/argo-cd/v2/common" "github.com/argoproj/argo-cd/v2/test/fixture/log" "github.com/argoproj/argo-cd/v2/test/fixture/path" "github.com/argoproj/argo-cd/v2/test/fixture/test" @@ -44,18 +45,6 @@ func TestEnsurePrefix(t *testing.T) { } } -func TestRemoveSuffix(t *testing.T) { - data := [][]string{ - {"hello.git", ".git", "hello"}, - {"hello", ".git", "hello"}, - {".git", ".git", ""}, - } - for _, table := range data { - result := removeSuffix(table[0], table[1]) - assert.Equal(t, table[2], result) - } -} - func TestIsSSHURL(t *testing.T) { data := map[string]bool{ "git://github.com/argoproj/test.git": false, @@ -146,19 +135,19 @@ func TestCustomHTTPClient(t *testing.T) { assert.NotEqual(t, "", string(keyData)) // Get HTTPSCreds with client cert creds specified, and insecure connection - creds := NewHTTPSCreds("test", "test", string(certData), string(keyData), false, "http://proxy:5000") + creds := NewHTTPSCreds("test", "test", string(certData), string(keyData), false, "http://proxy:5000", &NoopCredsStore{}, false) client := GetRepoHTTPClient("https://localhost:9443/foo/bar", false, creds, "http://proxy:5000") assert.NotNil(t, client) assert.NotNil(t, client.Transport) if client.Transport != nil { - httpClient := client.Transport.(*http.Transport) - assert.NotNil(t, httpClient.TLSClientConfig) - - assert.Equal(t, false, httpClient.TLSClientConfig.InsecureSkipVerify) - - assert.NotNil(t, httpClient.TLSClientConfig.GetClientCertificate) - if httpClient.TLSClientConfig.GetClientCertificate != nil { - cert, err := httpClient.TLSClientConfig.GetClientCertificate(nil) + transport := client.Transport.(*http.Transport) + assert.NotNil(t, transport.TLSClientConfig) + assert.Equal(t, true, transport.DisableKeepAlives) + assert.Equal(t, false, transport.TLSClientConfig.InsecureSkipVerify) + assert.NotNil(t, transport.TLSClientConfig.GetClientCertificate) + assert.Nil(t, transport.TLSClientConfig.RootCAs) + if transport.TLSClientConfig.GetClientCertificate != nil { + cert, err := transport.TLSClientConfig.GetClientCertificate(nil) assert.NoError(t, err) if err == nil { assert.NotNil(t, cert) @@ -166,30 +155,27 @@ func TestCustomHTTPClient(t *testing.T) { assert.NotNil(t, cert.PrivateKey) } } - proxy, err := httpClient.Proxy(nil) + proxy, err := transport.Proxy(nil) assert.Nil(t, err) assert.Equal(t, "http://proxy:5000", proxy.String()) } - os.Setenv("http_proxy", "http://proxy-from-env:7878") - defer func() { - assert.Nil(t, os.Unsetenv("http_proxy")) - }() + t.Setenv("http_proxy", "http://proxy-from-env:7878") // Get HTTPSCreds without client cert creds, but insecure connection - creds = NewHTTPSCreds("test", "test", "", "", true, "") + creds = NewHTTPSCreds("test", "test", "", "", true, "", &NoopCredsStore{}, false) client = GetRepoHTTPClient("https://localhost:9443/foo/bar", true, creds, "") assert.NotNil(t, client) assert.NotNil(t, client.Transport) if client.Transport != nil { - httpClient := client.Transport.(*http.Transport) - assert.NotNil(t, httpClient.TLSClientConfig) - - assert.Equal(t, true, httpClient.TLSClientConfig.InsecureSkipVerify) - - assert.NotNil(t, httpClient.TLSClientConfig.GetClientCertificate) - if httpClient.TLSClientConfig.GetClientCertificate != nil { - cert, err := httpClient.TLSClientConfig.GetClientCertificate(nil) + transport := client.Transport.(*http.Transport) + assert.NotNil(t, transport.TLSClientConfig) + assert.Equal(t, true, transport.DisableKeepAlives) + assert.Equal(t, true, transport.TLSClientConfig.InsecureSkipVerify) + assert.NotNil(t, transport.TLSClientConfig.GetClientCertificate) + assert.Nil(t, transport.TLSClientConfig.RootCAs) + if transport.TLSClientConfig.GetClientCertificate != nil { + cert, err := transport.TLSClientConfig.GetClientCertificate(nil) assert.NoError(t, err) if err == nil { assert.NotNil(t, cert) @@ -197,12 +183,30 @@ func TestCustomHTTPClient(t *testing.T) { assert.Nil(t, cert.PrivateKey) } } - req, err := http.NewRequest("GET", "http://proxy-from-env:7878", nil) + req, err := http.NewRequest(http.MethodGet, "http://proxy-from-env:7878", nil) assert.Nil(t, err) - proxy, err := httpClient.Proxy(req) + proxy, err := transport.Proxy(req) assert.Nil(t, err) assert.Equal(t, "http://proxy-from-env:7878", proxy.String()) } + // GetRepoHTTPClient with root ca + cert, err := os.ReadFile("../../test/fixture/certs/argocd-test-server.crt") + assert.NoError(t, err) + temppath := t.TempDir() + defer os.RemoveAll(temppath) + err = os.WriteFile(filepath.Join(temppath, "127.0.0.1"), cert, 0666) + assert.NoError(t, err) + t.Setenv(common.EnvVarTLSDataPath, temppath) + client = GetRepoHTTPClient("https://127.0.0.1", false, creds, "") + assert.NotNil(t, client) + assert.NotNil(t, client.Transport) + if client.Transport != nil { + transport := client.Transport.(*http.Transport) + assert.NotNil(t, transport.TLSClientConfig) + assert.Equal(t, true, transport.DisableKeepAlives) + assert.Equal(t, false, transport.TLSClientConfig.InsecureSkipVerify) + assert.NotNil(t, transport.TLSClientConfig.RootCAs) + } } func TestLsRemote(t *testing.T) { @@ -245,11 +249,7 @@ func TestLFSClient(t *testing.T) { // TODO(alexmt): dockerize tests in and enabled it t.Skip() - tempDir, err := os.MkdirTemp("", "git-client-lfs-test-") - assert.NoError(t, err) - if err == nil { - defer func() { _ = os.RemoveAll(tempDir) }() - } + tempDir := t.TempDir() client, err := NewClientExt("https://github.com/argoproj-labs/argocd-testrepo-lfs", tempDir, NopCreds{}, false, true, "") assert.NoError(t, err) @@ -264,7 +264,7 @@ func TestLFSClient(t *testing.T) { err = client.Fetch("") assert.NoError(t, err) - err = client.Checkout(commitSHA) + err = client.Checkout(commitSHA, true) assert.NoError(t, err) largeFiles, err := client.LsLargeFiles() @@ -274,7 +274,11 @@ func TestLFSClient(t *testing.T) { fileHandle, err := os.Open(fmt.Sprintf("%s/test3.yaml", tempDir)) assert.NoError(t, err) if err == nil { - defer fileHandle.Close() + defer func() { + if err = fileHandle.Close(); err != nil { + assert.NoError(t, err) + } + }() text, err := io.ReadAll(fileHandle) assert.NoError(t, err) if err == nil { @@ -284,11 +288,7 @@ func TestLFSClient(t *testing.T) { } func TestVerifyCommitSignature(t *testing.T) { - p, err := os.MkdirTemp("", "test-verify-commit-sig") - if err != nil { - panic(err.Error()) - } - defer os.RemoveAll(p) + p := t.TempDir() client, err := NewClientExt("https://github.com/argoproj/argo-cd.git", p, NopCreds{}, false, false, "") assert.NoError(t, err) @@ -302,7 +302,7 @@ func TestVerifyCommitSignature(t *testing.T) { commitSHA, err := client.LsRemote("HEAD") assert.NoError(t, err) - err = client.Checkout(commitSHA) + err = client.Checkout(commitSHA, true) assert.NoError(t, err) // 28027897aad1262662096745f2ce2d4c74d02b7f is a commit that is signed in the repo @@ -343,9 +343,7 @@ func TestNewFactory(t *testing.T) { test.Flaky(t) } - dirName, err := os.MkdirTemp("", "git-client-test-") - assert.NoError(t, err) - defer func() { _ = os.RemoveAll(dirName) }() + dirName := t.TempDir() client, err := NewClientExt(tt.args.url, dirName, NopCreds{}, tt.args.insecureIgnoreHostKey, false, "") assert.NoError(t, err) @@ -362,7 +360,7 @@ func TestNewFactory(t *testing.T) { err = client.Fetch("") assert.NoError(t, err) - err = client.Checkout(commitSHA) + err = client.Checkout(commitSHA, true) assert.NoError(t, err) revisionMetadata, err := client.RevisionMetadata(commitSHA) @@ -381,11 +379,7 @@ func TestNewFactory(t *testing.T) { } func TestListRevisions(t *testing.T) { - dir, err := os.MkdirTemp("", "test-list-revisions") - if err != nil { - panic(err.Error()) - } - defer os.RemoveAll(dir) + dir := t.TempDir() repoURL := "https://github.com/argoproj/argo-cd.git" client, err := NewClientExt(repoURL, dir, NopCreds{}, false, false, "") @@ -402,3 +396,54 @@ func TestListRevisions(t *testing.T) { assert.NotContains(t, lsResult.Branches, testTag) assert.NotContains(t, lsResult.Tags, testBranch) } + +func TestLsFiles(t *testing.T) { + tmpDir1 := t.TempDir() + tmpDir2 := t.TempDir() + + client, err := NewClientExt("", tmpDir1, NopCreds{}, false, false, "") + assert.NoError(t, err) + + err = runCmd(tmpDir1, "git", "init") + assert.NoError(t, err) + + // Prepare files + a, err := os.Create(filepath.Join(tmpDir1, "a.yaml")) + assert.NoError(t, err) + a.Close() + err = os.MkdirAll(filepath.Join(tmpDir1, "subdir"), 0755) + assert.NoError(t, err) + b, err := os.Create(filepath.Join(tmpDir1, "subdir", "b.yaml")) + assert.NoError(t, err) + b.Close() + err = os.MkdirAll(filepath.Join(tmpDir2, "subdir"), 0755) + assert.NoError(t, err) + c, err := os.Create(filepath.Join(tmpDir2, "c.yaml")) + assert.NoError(t, err) + c.Close() + err = os.Symlink(filepath.Join(tmpDir2, "c.yaml"), filepath.Join(tmpDir1, "link.yaml")) + assert.NoError(t, err) + + err = runCmd(tmpDir1, "git", "add", ".") + assert.NoError(t, err) + err = runCmd(tmpDir1, "git", "commit", "-m", "Initial commit") + assert.NoError(t, err) + + // Old and default globbing + expectedResult := []string{"a.yaml", "link.yaml", "subdir/b.yaml"} + lsResult, err := client.LsFiles("*.yaml", false) + assert.NoError(t, err) + assert.Equal(t, lsResult, expectedResult) + + // New and safer globbing, do not return symlinks resolving outside of the repo + expectedResult = []string{"a.yaml"} + lsResult, err = client.LsFiles("*.yaml", true) + assert.NoError(t, err) + assert.Equal(t, lsResult, expectedResult) + + // New globbing, do not return files outside of the repo + var nilResult []string + lsResult, err = client.LsFiles(filepath.Join(tmpDir2, "*.yaml"), true) + assert.NoError(t, err) + assert.Equal(t, lsResult, nilResult) +} diff --git a/ext/git/mocks/Client.go b/ext/git/mocks/Client.go index 645e065..6325228 100644 --- a/ext/git/mocks/Client.go +++ b/ext/git/mocks/Client.go @@ -1,11 +1,10 @@ -// Code generated by mockery v1.1.2. DO NOT EDIT. +// Code generated by mockery v2.43.0. DO NOT EDIT. package mocks import ( - mock "github.com/stretchr/testify/mock" - git "github.com/argoproj-labs/argocd-image-updater/ext/git" + mock "github.com/stretchr/testify/mock" ) // Client is an autogenerated mock type for the Client type @@ -17,6 +16,10 @@ type Client struct { func (_m *Client) Add(path string) error { ret := _m.Called(path) + if len(ret) == 0 { + panic("no return value specified for Add") + } + var r0 error if rf, ok := ret.Get(0).(func(string) error); ok { r0 = rf(path) @@ -31,6 +34,10 @@ func (_m *Client) Add(path string) error { func (_m *Client) Branch(sourceBranch string, targetBranch string) error { ret := _m.Called(sourceBranch, targetBranch) + if len(ret) == 0 { + panic("no return value specified for Branch") + } + var r0 error if rf, ok := ret.Get(0).(func(string, string) error); ok { r0 = rf(sourceBranch, targetBranch) @@ -41,13 +48,47 @@ func (_m *Client) Branch(sourceBranch string, targetBranch string) error { return r0 } -// Checkout provides a mock function with given fields: revision -func (_m *Client) Checkout(revision string) error { - ret := _m.Called(revision) +// ChangedFiles provides a mock function with given fields: revision, targetRevision +func (_m *Client) ChangedFiles(revision string, targetRevision string) ([]string, error) { + ret := _m.Called(revision, targetRevision) + + if len(ret) == 0 { + panic("no return value specified for ChangedFiles") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func(string, string) ([]string, error)); ok { + return rf(revision, targetRevision) + } + if rf, ok := ret.Get(0).(func(string, string) []string); ok { + r0 = rf(revision, targetRevision) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(revision, targetRevision) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Checkout provides a mock function with given fields: revision, submoduleEnabled +func (_m *Client) Checkout(revision string, submoduleEnabled bool) error { + ret := _m.Called(revision, submoduleEnabled) + + if len(ret) == 0 { + panic("no return value specified for Checkout") + } var r0 error - if rf, ok := ret.Get(0).(func(string) error); ok { - r0 = rf(revision) + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(revision, submoduleEnabled) } else { r0 = ret.Error(0) } @@ -59,6 +100,10 @@ func (_m *Client) Checkout(revision string) error { func (_m *Client) Commit(pathSpec string, opts *git.CommitOptions) error { ret := _m.Called(pathSpec, opts) + if len(ret) == 0 { + panic("no return value specified for Commit") + } + var r0 error if rf, ok := ret.Get(0).(func(string, *git.CommitOptions) error); ok { r0 = rf(pathSpec, opts) @@ -73,14 +118,21 @@ func (_m *Client) Commit(pathSpec string, opts *git.CommitOptions) error { func (_m *Client) CommitSHA() (string, error) { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for CommitSHA") + } + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } if rf, ok := ret.Get(0).(func() string); ok { r0 = rf() } else { r0 = ret.Get(0).(string) } - var r1 error if rf, ok := ret.Get(1).(func() error); ok { r1 = rf() } else { @@ -94,6 +146,10 @@ func (_m *Client) CommitSHA() (string, error) { func (_m *Client) Config(username string, email string) error { ret := _m.Called(username, email) + if len(ret) == 0 { + panic("no return value specified for Config") + } + var r0 error if rf, ok := ret.Get(0).(func(string, string) error); ok { r0 = rf(username, email) @@ -108,6 +164,10 @@ func (_m *Client) Config(username string, email string) error { func (_m *Client) Fetch(revision string) error { ret := _m.Called(revision) + if len(ret) == 0 { + panic("no return value specified for Fetch") + } + var r0 error if rf, ok := ret.Get(0).(func(string) error); ok { r0 = rf(revision) @@ -122,6 +182,10 @@ func (_m *Client) Fetch(revision string) error { func (_m *Client) Init() error { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for Init") + } + var r0 error if rf, ok := ret.Get(0).(func() error); ok { r0 = rf() @@ -132,22 +196,47 @@ func (_m *Client) Init() error { return r0 } -// LsFiles provides a mock function with given fields: path -func (_m *Client) LsFiles(path string) ([]string, error) { - ret := _m.Called(path) +// IsAnnotatedTag provides a mock function with given fields: _a0 +func (_m *Client) IsAnnotatedTag(_a0 string) bool { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for IsAnnotatedTag") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// LsFiles provides a mock function with given fields: path, enableNewGitFileGlobbing +func (_m *Client) LsFiles(path string, enableNewGitFileGlobbing bool) ([]string, error) { + ret := _m.Called(path, enableNewGitFileGlobbing) + + if len(ret) == 0 { + panic("no return value specified for LsFiles") + } var r0 []string - if rf, ok := ret.Get(0).(func(string) []string); ok { - r0 = rf(path) + var r1 error + if rf, ok := ret.Get(0).(func(string, bool) ([]string, error)); ok { + return rf(path, enableNewGitFileGlobbing) + } + if rf, ok := ret.Get(0).(func(string, bool) []string); ok { + r0 = rf(path, enableNewGitFileGlobbing) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } - var r1 error - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(path) + if rf, ok := ret.Get(1).(func(string, bool) error); ok { + r1 = rf(path, enableNewGitFileGlobbing) } else { r1 = ret.Error(1) } @@ -159,7 +248,15 @@ func (_m *Client) LsFiles(path string) ([]string, error) { func (_m *Client) LsLargeFiles() ([]string, error) { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for LsLargeFiles") + } + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func() ([]string, error)); ok { + return rf() + } if rf, ok := ret.Get(0).(func() []string); ok { r0 = rf() } else { @@ -168,7 +265,6 @@ func (_m *Client) LsLargeFiles() ([]string, error) { } } - var r1 error if rf, ok := ret.Get(1).(func() error); ok { r1 = rf() } else { @@ -182,7 +278,15 @@ func (_m *Client) LsLargeFiles() ([]string, error) { func (_m *Client) LsRefs() (*git.Refs, error) { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for LsRefs") + } + var r0 *git.Refs + var r1 error + if rf, ok := ret.Get(0).(func() (*git.Refs, error)); ok { + return rf() + } if rf, ok := ret.Get(0).(func() *git.Refs); ok { r0 = rf() } else { @@ -191,7 +295,6 @@ func (_m *Client) LsRefs() (*git.Refs, error) { } } - var r1 error if rf, ok := ret.Get(1).(func() error); ok { r1 = rf() } else { @@ -205,14 +308,21 @@ func (_m *Client) LsRefs() (*git.Refs, error) { func (_m *Client) LsRemote(revision string) (string, error) { ret := _m.Called(revision) + if len(ret) == 0 { + panic("no return value specified for LsRemote") + } + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(revision) + } if rf, ok := ret.Get(0).(func(string) string); ok { r0 = rf(revision) } else { r0 = ret.Get(0).(string) } - var r1 error if rf, ok := ret.Get(1).(func(string) error); ok { r1 = rf(revision) } else { @@ -226,6 +336,10 @@ func (_m *Client) LsRemote(revision string) (string, error) { func (_m *Client) Push(remote string, branch string, force bool) error { ret := _m.Called(remote, branch, force) + if len(ret) == 0 { + panic("no return value specified for Push") + } + var r0 error if rf, ok := ret.Get(0).(func(string, string, bool) error); ok { r0 = rf(remote, branch, force) @@ -240,7 +354,15 @@ func (_m *Client) Push(remote string, branch string, force bool) error { func (_m *Client) RevisionMetadata(revision string) (*git.RevisionMetadata, error) { ret := _m.Called(revision) + if len(ret) == 0 { + panic("no return value specified for RevisionMetadata") + } + var r0 *git.RevisionMetadata + var r1 error + if rf, ok := ret.Get(0).(func(string) (*git.RevisionMetadata, error)); ok { + return rf(revision) + } if rf, ok := ret.Get(0).(func(string) *git.RevisionMetadata); ok { r0 = rf(revision) } else { @@ -249,7 +371,6 @@ func (_m *Client) RevisionMetadata(revision string) (*git.RevisionMetadata, erro } } - var r1 error if rf, ok := ret.Get(1).(func(string) error); ok { r1 = rf(revision) } else { @@ -263,6 +384,10 @@ func (_m *Client) RevisionMetadata(revision string) (*git.RevisionMetadata, erro func (_m *Client) Root() string { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for Root") + } + var r0 string if rf, ok := ret.Get(0).(func() string); ok { r0 = rf() @@ -273,18 +398,43 @@ func (_m *Client) Root() string { return r0 } +// Submodule provides a mock function with given fields: +func (_m *Client) Submodule() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Submodule") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SymRefToBranch provides a mock function with given fields: symRef func (_m *Client) SymRefToBranch(symRef string) (string, error) { ret := _m.Called(symRef) + if len(ret) == 0 { + panic("no return value specified for SymRefToBranch") + } + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(symRef) + } if rf, ok := ret.Get(0).(func(string) string); ok { r0 = rf(symRef) } else { r0 = ret.Get(0).(string) } - var r1 error if rf, ok := ret.Get(1).(func(string) error); ok { r1 = rf(symRef) } else { @@ -298,14 +448,21 @@ func (_m *Client) SymRefToBranch(symRef string) (string, error) { func (_m *Client) VerifyCommitSignature(_a0 string) (string, error) { ret := _m.Called(_a0) + if len(ret) == 0 { + panic("no return value specified for VerifyCommitSignature") + } + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(_a0) + } if rf, ok := ret.Get(0).(func(string) string); ok { r0 = rf(_a0) } else { r0 = ret.Get(0).(string) } - var r1 error if rf, ok := ret.Get(1).(func(string) error); ok { r1 = rf(_a0) } else { @@ -314,3 +471,17 @@ func (_m *Client) VerifyCommitSignature(_a0 string) (string, error) { return r0, r1 } + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { + mock.TestingT + Cleanup(func()) +}) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/ext/git/ssh.go b/ext/git/ssh.go index eb07a05..a1cb337 100644 --- a/ext/git/ssh.go +++ b/ext/git/ssh.go @@ -11,14 +11,14 @@ import ( // Unfortunately, crypto/ssh does not offer public constants or list for // this. var SupportedSSHKeyExchangeAlgorithms = []string{ - "diffie-hellman-group1-sha1", - "diffie-hellman-group14-sha1", + "curve25519-sha256", + "curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", - "curve25519-sha256@libssh.org", - "diffie-hellman-group-exchange-sha1", "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", } // List of default key exchange algorithms to use. We use those that are diff --git a/ext/git/workaround.go b/ext/git/workaround.go index c364c09..4763612 100644 --- a/ext/git/workaround.go +++ b/ext/git/workaround.go @@ -1,6 +1,9 @@ package git import ( + "fmt" + neturl "net/url" + "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport" @@ -30,6 +33,23 @@ func newClient(url string, insecure bool, creds Creds, proxy string) (transport. if !IsHTTPSURL(url) && !IsHTTPURL(url) { // use the default client for protocols other than HTTP/HTTPS + ep.InsecureSkipTLS = insecure + if proxy != "" { + parsedProxyURL, err := neturl.Parse(proxy) + if err != nil { + return nil, nil, fmt.Errorf("failed to create client for url '%s', error parsing proxy url '%s': %w", url, proxy, err) + } + var proxyUsername, proxyPasswd string + if parsedProxyURL.User != nil { + proxyUsername = parsedProxyURL.User.Username() + proxyPasswd, _ = parsedProxyURL.User.Password() + } + ep.Proxy = transport.ProxyOptions{ + URL: fmt.Sprintf("%s://%s:%s", parsedProxyURL.Scheme, parsedProxyURL.Hostname(), parsedProxyURL.Port()), + Username: proxyUsername, + Password: proxyPasswd, + } + } c, err := client.NewClient(ep) if err != nil { return nil, nil, err diff --git a/ext/git/writer.go b/ext/git/writer.go index 4dede58..a2f4cc6 100644 --- a/ext/git/writer.go +++ b/ext/git/writer.go @@ -79,7 +79,7 @@ func (m *nativeGitClient) Push(remote string, branch string, force bool) error { args = append(args, "-f") } args = append(args, remote, branch) - err := m.runCredentialedCmd("git", args...) + err := m.runCredentialedCmd(args...) if err != nil { return fmt.Errorf("could not push %s to %s: %v", branch, remote, err) } |
