git: refactor AuthStrategy into AuthOptions
This commit moves the previous `AuthStrategy` wiring to a more generic `AuthOptions`, breaking free from implementation specific details in the `git` package. Signed-off-by: Hidde Beydals <hello@hidde.co>
This commit is contained in:
parent
46a5b9c27d
commit
0cf0d4e756
|
@ -229,34 +229,23 @@ func (r *GitRepositoryReconciler) reconcile(ctx context.Context, repository sour
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpGit)
|
defer os.RemoveAll(tmpGit)
|
||||||
|
|
||||||
// determine auth method
|
// Configure auth options using secret
|
||||||
auth := &git.Auth{}
|
authOpts := &git.AuthOptions{}
|
||||||
if repository.Spec.SecretRef != nil {
|
if repository.Spec.SecretRef != nil {
|
||||||
authStrategy, err := strategy.AuthSecretStrategyForURL(
|
|
||||||
repository.Spec.URL,
|
|
||||||
git.CheckoutOptions{
|
|
||||||
GitImplementation: repository.Spec.GitImplementation,
|
|
||||||
RecurseSubmodules: repository.Spec.RecurseSubmodules,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return sourcev1.GitRepositoryNotReady(repository, sourcev1.AuthenticationFailedReason, err.Error()), err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := types.NamespacedName{
|
name := types.NamespacedName{
|
||||||
Namespace: repository.GetNamespace(),
|
Namespace: repository.GetNamespace(),
|
||||||
Name: repository.Spec.SecretRef.Name,
|
Name: repository.Spec.SecretRef.Name,
|
||||||
}
|
}
|
||||||
|
|
||||||
var secret corev1.Secret
|
secret := &corev1.Secret{}
|
||||||
err = r.Client.Get(ctx, name, &secret)
|
err = r.Client.Get(ctx, name, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("auth secret error: %w", err)
|
err = fmt.Errorf("auth secret error: %w", err)
|
||||||
return sourcev1.GitRepositoryNotReady(repository, sourcev1.AuthenticationFailedReason, err.Error()), err
|
return sourcev1.GitRepositoryNotReady(repository, sourcev1.AuthenticationFailedReason, err.Error()), err
|
||||||
}
|
}
|
||||||
|
|
||||||
auth, err = authStrategy.Method(secret)
|
authOpts, err = git.AuthOptionsFromSecret(repository.Spec.URL, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("auth error: %w", err)
|
|
||||||
return sourcev1.GitRepositoryNotReady(repository, sourcev1.AuthenticationFailedReason, err.Error()), err
|
return sourcev1.GitRepositoryNotReady(repository, sourcev1.AuthenticationFailedReason, err.Error()), err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -275,7 +264,7 @@ func (r *GitRepositoryReconciler) reconcile(ctx context.Context, repository sour
|
||||||
gitCtx, cancel := context.WithTimeout(ctx, repository.Spec.Timeout.Duration)
|
gitCtx, cancel := context.WithTimeout(ctx, repository.Spec.Timeout.Duration)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
commit, revision, err := checkoutStrategy.Checkout(gitCtx, tmpGit, repository.Spec.URL, auth)
|
commit, revision, err := checkoutStrategy.Checkout(gitCtx, tmpGit, repository.Spec.URL, authOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sourcev1.GitRepositoryNotReady(repository, sourcev1.GitOperationFailedReason, err.Error()), err
|
return sourcev1.GitRepositoryNotReady(repository, sourcev1.GitOperationFailedReason, err.Error()), err
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,41 +19,14 @@ package git
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
||||||
git2go "github.com/libgit2/git2go/v31"
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
DefaultOrigin = "origin"
|
|
||||||
DefaultBranch = "master"
|
|
||||||
DefaultPublicKeyAuthUser = "git"
|
|
||||||
CAFile = "caFile"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Commit interface {
|
type Commit interface {
|
||||||
Verify(secret corev1.Secret) error
|
Verify(secret corev1.Secret) error
|
||||||
Hash() string
|
Hash() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutStrategy interface {
|
type CheckoutStrategy interface {
|
||||||
Checkout(ctx context.Context, path, url string, auth *Auth) (Commit, string, error)
|
Checkout(ctx context.Context, path, url string, config *AuthOptions) (Commit, string, error)
|
||||||
}
|
|
||||||
|
|
||||||
type CheckoutOptions struct {
|
|
||||||
GitImplementation string
|
|
||||||
RecurseSubmodules bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(hidde): candidate for refactoring, so that we do not directly
|
|
||||||
// depend on implementation specifics here.
|
|
||||||
type Auth struct {
|
|
||||||
AuthMethod transport.AuthMethod
|
|
||||||
CABundle []byte
|
|
||||||
CredCallback git2go.CredentialsCallback
|
|
||||||
CertCallback git2go.CertificateCheckCallback
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthSecretStrategy interface {
|
|
||||||
Method(secret corev1.Secret) (*Auth, error)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,11 +23,10 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/semver/v3"
|
"github.com/Masterminds/semver/v3"
|
||||||
extgogit "github.com/go-git/go-git/v5"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
|
||||||
|
|
||||||
"github.com/fluxcd/pkg/gitutil"
|
"github.com/fluxcd/pkg/gitutil"
|
||||||
"github.com/fluxcd/pkg/version"
|
"github.com/fluxcd/pkg/version"
|
||||||
|
extgogit "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
|
||||||
sourcev1 "github.com/fluxcd/source-controller/api/v1beta1"
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta1"
|
||||||
"github.com/fluxcd/source-controller/pkg/git"
|
"github.com/fluxcd/source-controller/pkg/git"
|
||||||
|
@ -59,10 +58,14 @@ type CheckoutBranch struct {
|
||||||
recurseSubmodules bool
|
recurseSubmodules bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CheckoutBranch) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) {
|
func (c *CheckoutBranch) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (git.Commit, string, error) {
|
||||||
|
authMethod, err := transportAuth(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("could not construct auth method: %w", err)
|
||||||
|
}
|
||||||
repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{
|
repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{
|
||||||
URL: url,
|
URL: url,
|
||||||
Auth: auth.AuthMethod,
|
Auth: authMethod,
|
||||||
RemoteName: git.DefaultOrigin,
|
RemoteName: git.DefaultOrigin,
|
||||||
ReferenceName: plumbing.NewBranchReferenceName(c.branch),
|
ReferenceName: plumbing.NewBranchReferenceName(c.branch),
|
||||||
SingleBranch: true,
|
SingleBranch: true,
|
||||||
|
@ -71,7 +74,7 @@ func (c *CheckoutBranch) Checkout(ctx context.Context, path, url string, auth *g
|
||||||
RecurseSubmodules: recurseSubmodules(c.recurseSubmodules),
|
RecurseSubmodules: recurseSubmodules(c.recurseSubmodules),
|
||||||
Progress: nil,
|
Progress: nil,
|
||||||
Tags: extgogit.NoTags,
|
Tags: extgogit.NoTags,
|
||||||
CABundle: auth.CABundle,
|
CABundle: opts.CAFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, gitutil.GoGitError(err))
|
return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, gitutil.GoGitError(err))
|
||||||
|
@ -92,10 +95,14 @@ type CheckoutTag struct {
|
||||||
recurseSubmodules bool
|
recurseSubmodules bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CheckoutTag) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) {
|
func (c *CheckoutTag) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (git.Commit, string, error) {
|
||||||
|
authMethod, err := transportAuth(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("could not construct auth method: %w", err)
|
||||||
|
}
|
||||||
repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{
|
repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{
|
||||||
URL: url,
|
URL: url,
|
||||||
Auth: auth.AuthMethod,
|
Auth: authMethod,
|
||||||
RemoteName: git.DefaultOrigin,
|
RemoteName: git.DefaultOrigin,
|
||||||
ReferenceName: plumbing.NewTagReferenceName(c.tag),
|
ReferenceName: plumbing.NewTagReferenceName(c.tag),
|
||||||
SingleBranch: true,
|
SingleBranch: true,
|
||||||
|
@ -104,7 +111,7 @@ func (c *CheckoutTag) Checkout(ctx context.Context, path, url string, auth *git.
|
||||||
RecurseSubmodules: recurseSubmodules(c.recurseSubmodules),
|
RecurseSubmodules: recurseSubmodules(c.recurseSubmodules),
|
||||||
Progress: nil,
|
Progress: nil,
|
||||||
Tags: extgogit.NoTags,
|
Tags: extgogit.NoTags,
|
||||||
CABundle: auth.CABundle,
|
CABundle: opts.CAFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, err)
|
return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, err)
|
||||||
|
@ -126,10 +133,14 @@ type CheckoutCommit struct {
|
||||||
recurseSubmodules bool
|
recurseSubmodules bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CheckoutCommit) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) {
|
func (c *CheckoutCommit) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (git.Commit, string, error) {
|
||||||
|
authMethod, err := transportAuth(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("could not construct transportAuth method: %w", err)
|
||||||
|
}
|
||||||
repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{
|
repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{
|
||||||
URL: url,
|
URL: url,
|
||||||
Auth: auth.AuthMethod,
|
Auth: authMethod,
|
||||||
RemoteName: git.DefaultOrigin,
|
RemoteName: git.DefaultOrigin,
|
||||||
ReferenceName: plumbing.NewBranchReferenceName(c.branch),
|
ReferenceName: plumbing.NewBranchReferenceName(c.branch),
|
||||||
SingleBranch: true,
|
SingleBranch: true,
|
||||||
|
@ -137,7 +148,7 @@ func (c *CheckoutCommit) Checkout(ctx context.Context, path, url string, auth *g
|
||||||
RecurseSubmodules: recurseSubmodules(c.recurseSubmodules),
|
RecurseSubmodules: recurseSubmodules(c.recurseSubmodules),
|
||||||
Progress: nil,
|
Progress: nil,
|
||||||
Tags: extgogit.NoTags,
|
Tags: extgogit.NoTags,
|
||||||
CABundle: auth.CABundle,
|
CABundle: opts.CAFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, err)
|
return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, err)
|
||||||
|
@ -165,22 +176,25 @@ type CheckoutSemVer struct {
|
||||||
recurseSubmodules bool
|
recurseSubmodules bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) {
|
func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (git.Commit, string, error) {
|
||||||
verConstraint, err := semver.NewConstraint(c.semVer)
|
verConstraint, err := semver.NewConstraint(c.semVer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("semver parse range error: %w", err)
|
return nil, "", fmt.Errorf("semver parse range error: %w", err)
|
||||||
}
|
}
|
||||||
|
authMethod, err := transportAuth(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("could not construct transportAuth method: %w", err)
|
||||||
|
}
|
||||||
repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{
|
repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{
|
||||||
URL: url,
|
URL: url,
|
||||||
Auth: auth.AuthMethod,
|
Auth: authMethod,
|
||||||
RemoteName: git.DefaultOrigin,
|
RemoteName: git.DefaultOrigin,
|
||||||
NoCheckout: false,
|
NoCheckout: false,
|
||||||
Depth: 1,
|
Depth: 1,
|
||||||
RecurseSubmodules: recurseSubmodules(c.recurseSubmodules),
|
RecurseSubmodules: recurseSubmodules(c.recurseSubmodules),
|
||||||
Progress: nil,
|
Progress: nil,
|
||||||
Tags: extgogit.AllTags,
|
Tags: extgogit.AllTags,
|
||||||
CABundle: auth.CABundle,
|
CABundle: opts.CAFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, err)
|
return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, err)
|
||||||
|
|
|
@ -25,7 +25,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCheckoutTagSemVer_Checkout(t *testing.T) {
|
func TestCheckoutTagSemVer_Checkout(t *testing.T) {
|
||||||
auth := &git.Auth{}
|
auth := &git.AuthOptions{}
|
||||||
tag := CheckoutTag{
|
tag := CheckoutTag{
|
||||||
tag: "v1.7.0",
|
tag: "v1.7.0",
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,88 +17,39 @@ limitations under the License.
|
||||||
package gogit
|
package gogit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
|
|
||||||
"github.com/fluxcd/pkg/ssh/knownhosts"
|
"github.com/fluxcd/pkg/ssh/knownhosts"
|
||||||
|
|
||||||
"github.com/fluxcd/source-controller/pkg/git"
|
"github.com/fluxcd/source-controller/pkg/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthSecretStrategyForURL(URL string) (git.AuthSecretStrategy, error) {
|
// transportAuth constructs the transport.AuthMethod for the git.Transport of
|
||||||
u, err := url.Parse(URL)
|
// the given git.AuthOptions. It returns the result, or an error.
|
||||||
if err != nil {
|
func transportAuth(opts *git.AuthOptions) (transport.AuthMethod, error) {
|
||||||
return nil, fmt.Errorf("failed to parse URL to determine auth strategy: %w", err)
|
switch opts.Transport {
|
||||||
}
|
case git.HTTPS, git.HTTP:
|
||||||
|
return &http.BasicAuth{
|
||||||
switch {
|
Username: opts.Username,
|
||||||
case u.Scheme == "http", u.Scheme == "https":
|
Password: opts.Password,
|
||||||
return &BasicAuth{}, nil
|
}, nil
|
||||||
case u.Scheme == "ssh":
|
case git.SSH:
|
||||||
return &PublicKeyAuth{user: u.User.Username()}, nil
|
if len(opts.Identity) > 0 {
|
||||||
default:
|
pk, err := ssh.NewPublicKeys(opts.Username, opts.Identity, opts.Password)
|
||||||
return nil, fmt.Errorf("no auth secret strategy for scheme %s", u.Scheme)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(opts.KnownHosts) > 0 {
|
||||||
|
callback, err := knownhosts.New(opts.KnownHosts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pk.HostKeyCallback = callback
|
||||||
|
}
|
||||||
|
return pk, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return nil, nil
|
||||||
|
|
||||||
type BasicAuth struct{}
|
|
||||||
|
|
||||||
func (s *BasicAuth) Method(secret corev1.Secret) (*git.Auth, error) {
|
|
||||||
auth := &git.Auth{}
|
|
||||||
basicAuth := &http.BasicAuth{}
|
|
||||||
|
|
||||||
if caBundle, ok := secret.Data[git.CAFile]; ok {
|
|
||||||
auth.CABundle = caBundle
|
|
||||||
}
|
|
||||||
if username, ok := secret.Data["username"]; ok {
|
|
||||||
basicAuth.Username = string(username)
|
|
||||||
}
|
|
||||||
if password, ok := secret.Data["password"]; ok {
|
|
||||||
basicAuth.Password = string(password)
|
|
||||||
}
|
|
||||||
if (basicAuth.Username == "" && basicAuth.Password != "") || (basicAuth.Username != "" && basicAuth.Password == "") {
|
|
||||||
return nil, fmt.Errorf("invalid '%s' secret data: required fields 'username' and 'password'", secret.Name)
|
|
||||||
}
|
|
||||||
if basicAuth.Username != "" && basicAuth.Password != "" {
|
|
||||||
auth.AuthMethod = basicAuth
|
|
||||||
}
|
|
||||||
return auth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type PublicKeyAuth struct {
|
|
||||||
user string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PublicKeyAuth) Method(secret corev1.Secret) (*git.Auth, error) {
|
|
||||||
if _, ok := secret.Data[git.CAFile]; ok {
|
|
||||||
return nil, fmt.Errorf("found caFile key in secret '%s' but go-git SSH transport does not support custom certificates", secret.Name)
|
|
||||||
}
|
|
||||||
identity := secret.Data["identity"]
|
|
||||||
knownHosts := secret.Data["known_hosts"]
|
|
||||||
if len(identity) == 0 || len(knownHosts) == 0 {
|
|
||||||
return nil, fmt.Errorf("invalid '%s' secret data: required fields 'identity' and 'known_hosts'", secret.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
user := s.user
|
|
||||||
if user == "" {
|
|
||||||
user = git.DefaultPublicKeyAuthUser
|
|
||||||
}
|
|
||||||
|
|
||||||
password := secret.Data["password"]
|
|
||||||
pk, err := ssh.NewPublicKeys(user, identity, string(password))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
callback, err := knownhosts.New(knownHosts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pk.HostKeyCallback = callback
|
|
||||||
|
|
||||||
return &git.Auth{AuthMethod: pk}, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,19 +17,21 @@ limitations under the License.
|
||||||
package gogit
|
package gogit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||||
corev1 "k8s.io/api/core/v1"
|
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
|
||||||
"github.com/fluxcd/source-controller/pkg/git"
|
"github.com/fluxcd/source-controller/pkg/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// secretKeyFixture is a randomly generated password less
|
// privateKeyFixture is a randomly generated password less
|
||||||
// 512bit RSA private key.
|
// 512bit RSA private key.
|
||||||
secretKeyFixture string = `-----BEGIN RSA PRIVATE KEY-----
|
privateKeyFixture = `-----BEGIN RSA PRIVATE KEY-----
|
||||||
MIICXAIBAAKBgQCrakELAKxozvwJijQEggYlTvS1QTZx1DaBwOhW/4kRSuR21plu
|
MIICXAIBAAKBgQCrakELAKxozvwJijQEggYlTvS1QTZx1DaBwOhW/4kRSuR21plu
|
||||||
xuQeyuUiztoWeb9jgW7wjzG4j1PIJjdbsgjPIcIZ4PBY7JeEW+QRopfwuN8MHXNp
|
xuQeyuUiztoWeb9jgW7wjzG4j1PIJjdbsgjPIcIZ4PBY7JeEW+QRopfwuN8MHXNp
|
||||||
uTLgIHbkmhoOg5qBEcjzO/lEOOPpV0EmbObgqv3+wRmLJrgfzWl/cTtRewIDAQAB
|
uTLgIHbkmhoOg5qBEcjzO/lEOOPpV0EmbObgqv3+wRmLJrgfzWl/cTtRewIDAQAB
|
||||||
|
@ -45,9 +47,9 @@ Ngkgu4mLjc3RfenEhJECQAx8zjWUE6kHHPGAd9DfiAIQ4bChqnyS0Nwb9+Gd4hSE
|
||||||
P0Ah10mHiK/M0o3T8Eanwum0gbQHPnOwqZgsPkwXRqQ=
|
P0Ah10mHiK/M0o3T8Eanwum0gbQHPnOwqZgsPkwXRqQ=
|
||||||
-----END RSA PRIVATE KEY-----`
|
-----END RSA PRIVATE KEY-----`
|
||||||
|
|
||||||
// secretKeyFixture is a randomly generated
|
// privateKeyPassphraseFixture is a randomly generated
|
||||||
// 512bit RSA private key with password foobar.
|
// 512bit RSA private key with password foobar.
|
||||||
secretPassphraseFixture = `-----BEGIN RSA PRIVATE KEY-----
|
privateKeyPassphraseFixture = `-----BEGIN RSA PRIVATE KEY-----
|
||||||
Proc-Type: 4,ENCRYPTED
|
Proc-Type: 4,ENCRYPTED
|
||||||
DEK-Info: AES-256-CBC,0B016973B2A761D31E6B388D0F327C35
|
DEK-Info: AES-256-CBC,0B016973B2A761D31E6B388D0F327C35
|
||||||
|
|
||||||
|
@ -60,137 +62,133 @@ wGctSx4kHsZGhJv5qwKqqPEFPhUzph8D2tm2TABk8HJa5KJFDbGrcfvk2uODAoZr
|
||||||
MbcpIxCfl8oB09bWfY6tDQjyvwSYYo2Phdwm7kT92xc=
|
MbcpIxCfl8oB09bWfY6tDQjyvwSYYo2Phdwm7kT92xc=
|
||||||
-----END RSA PRIVATE KEY-----`
|
-----END RSA PRIVATE KEY-----`
|
||||||
|
|
||||||
// generated with sshkey-gen with password `password`. Fails test
|
|
||||||
secretEDCSAFicture = `-----BEGIN OPENSSH PRIVATE KEY-----
|
|
||||||
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCUNUDYpS
|
|
||||||
GJ0GjHSoOJvNzrAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIAUwMlCdqwINTCFe
|
|
||||||
0QTLK2w04AMyMDkH4keEHnTDB9KAAAAAoLv9vPS65ie3CQ9XYDXhX4TQUKg15kYmbt/Lqu
|
|
||||||
Eg5i6G2aJOIeq/ZwBOjySG328zucwptzScx1bgwIHfkPmUSBBoATcilGtglVFDmBuYSrky
|
|
||||||
r2bP9MJYmUIx3RkMZI0RcYIwuH/fMNPnyBbGMCwEEZP3xYXst8oNyGz47s9k6Woqy64bgh
|
|
||||||
Q0YEW1Vyqn/Tt8nBJrbtyY1iLnQjOZ167bYxc=
|
|
||||||
-----END OPENSSH PRIVATE KEY-----`
|
|
||||||
|
|
||||||
// knownHostsFixture is known_hosts fixture in the expected
|
// knownHostsFixture is known_hosts fixture in the expected
|
||||||
// format.
|
// format.
|
||||||
knownHostsFixture string = `github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==`
|
knownHostsFixture string = `github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==`
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
func Test_transportAuth(t *testing.T) {
|
||||||
basicAuthSecretFixture = corev1.Secret{
|
|
||||||
Data: map[string][]byte{
|
|
||||||
"username": []byte("git"),
|
|
||||||
"password": []byte("password"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
privateKeySecretFixture = corev1.Secret{
|
|
||||||
Data: map[string][]byte{
|
|
||||||
"identity": []byte(secretKeyFixture),
|
|
||||||
"known_hosts": []byte(knownHostsFixture),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
privateKeySecretWithPassphraseFixture = corev1.Secret{
|
|
||||||
Data: map[string][]byte{
|
|
||||||
"identity": []byte(secretPassphraseFixture),
|
|
||||||
"known_hosts": []byte(knownHostsFixture),
|
|
||||||
"password": []byte("foobar"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
failingPrivateKey = corev1.Secret{
|
|
||||||
Data: map[string][]byte{
|
|
||||||
"identity": []byte(secretEDCSAFicture),
|
|
||||||
"known_hosts": []byte(knownHostsFixture),
|
|
||||||
"password": []byte("password"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAuthSecretStrategyForURL(t *testing.T) {
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
url string
|
opts *git.AuthOptions
|
||||||
want git.AuthSecretStrategy
|
wantFunc func(g *WithT, t transport.AuthMethod, opts *git.AuthOptions)
|
||||||
wantErr bool
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{"HTTP", "http://git.example.com/org/repo.git", &BasicAuth{}, false},
|
{
|
||||||
{"HTTPS", "https://git.example.com/org/repo.git", &BasicAuth{}, false},
|
name: "HTTP basic auth",
|
||||||
{"SSH", "ssh://git.example.com:2222/org/repo.git", &PublicKeyAuth{}, false},
|
opts: &git.AuthOptions{
|
||||||
{"SSH with username", "ssh://example@git.example.com:2222/org/repo.git", &PublicKeyAuth{user: "example"}, false},
|
Transport: git.HTTP,
|
||||||
{"unsupported", "protocol://example.com", nil, true},
|
Username: "example",
|
||||||
|
Password: "password",
|
||||||
|
},
|
||||||
|
wantFunc: func(g *WithT, t transport.AuthMethod, opts *git.AuthOptions) {
|
||||||
|
g.Expect(t).To(Equal(&http.BasicAuth{
|
||||||
|
Username: opts.Username,
|
||||||
|
Password: opts.Password,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTPS basic auth",
|
||||||
|
opts: &git.AuthOptions{
|
||||||
|
Transport: git.HTTPS,
|
||||||
|
Username: "example",
|
||||||
|
Password: "password",
|
||||||
|
},
|
||||||
|
wantFunc: func(g *WithT, t transport.AuthMethod, opts *git.AuthOptions) {
|
||||||
|
g.Expect(t).To(Equal(&http.BasicAuth{
|
||||||
|
Username: opts.Username,
|
||||||
|
Password: opts.Password,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH private key",
|
||||||
|
opts: &git.AuthOptions{
|
||||||
|
Transport: git.SSH,
|
||||||
|
Username: "example",
|
||||||
|
Identity: []byte(privateKeyFixture),
|
||||||
|
},
|
||||||
|
wantFunc: func(g *WithT, t transport.AuthMethod, opts *git.AuthOptions) {
|
||||||
|
tt, ok := t.(*ssh.PublicKeys)
|
||||||
|
g.Expect(ok).To(BeTrue())
|
||||||
|
g.Expect(tt.User).To(Equal(opts.Username))
|
||||||
|
g.Expect(tt.Signer.PublicKey().Type()).To(Equal("ssh-rsa"))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH private key with passphrase",
|
||||||
|
opts: &git.AuthOptions{
|
||||||
|
Transport: git.SSH,
|
||||||
|
Username: "example",
|
||||||
|
Password: "foobar",
|
||||||
|
Identity: []byte(privateKeyPassphraseFixture),
|
||||||
|
},
|
||||||
|
wantFunc: func(g *WithT, t transport.AuthMethod, opts *git.AuthOptions) {
|
||||||
|
tt, ok := t.(*ssh.PublicKeys)
|
||||||
|
g.Expect(ok).To(BeTrue())
|
||||||
|
g.Expect(tt.User).To(Equal(opts.Username))
|
||||||
|
g.Expect(tt.Signer.PublicKey().Type()).To(Equal("ssh-rsa"))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH private key with invalid passphrase",
|
||||||
|
opts: &git.AuthOptions{
|
||||||
|
Transport: git.SSH,
|
||||||
|
Username: "example",
|
||||||
|
Password: "",
|
||||||
|
Identity: []byte(privateKeyPassphraseFixture),
|
||||||
|
},
|
||||||
|
wantErr: errors.New("x509: decryption password incorrect"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH private key with known_hosts",
|
||||||
|
opts: &git.AuthOptions{
|
||||||
|
Transport: git.SSH,
|
||||||
|
Username: "example",
|
||||||
|
Identity: []byte(privateKeyFixture),
|
||||||
|
KnownHosts: []byte(knownHostsFixture),
|
||||||
|
},
|
||||||
|
wantFunc: func(g *WithT, t transport.AuthMethod, opts *git.AuthOptions) {
|
||||||
|
tt, ok := t.(*ssh.PublicKeys)
|
||||||
|
g.Expect(ok).To(BeTrue())
|
||||||
|
g.Expect(tt.User).To(Equal(opts.Username))
|
||||||
|
g.Expect(tt.Signer.PublicKey().Type()).To(Equal("ssh-rsa"))
|
||||||
|
g.Expect(tt.HostKeyCallback).ToNot(BeNil())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH private key with invalid known_hosts",
|
||||||
|
opts: &git.AuthOptions{
|
||||||
|
Transport: git.SSH,
|
||||||
|
Username: "example",
|
||||||
|
Identity: []byte(privateKeyFixture),
|
||||||
|
KnownHosts: []byte("invalid"),
|
||||||
|
},
|
||||||
|
wantErr: errors.New("knownhosts: knownhosts: missing host pattern"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty",
|
||||||
|
opts: &git.AuthOptions{},
|
||||||
|
wantFunc: func(g *WithT, t transport.AuthMethod, opts *git.AuthOptions) {
|
||||||
|
g.Expect(t).To(BeNil())
|
||||||
|
},
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := AuthSecretStrategyForURL(tt.url)
|
g := NewWithT(t)
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("AuthSecretStrategyForURL() error = %v, wantErr %v", err, tt.wantErr)
|
got, err := transportAuth(tt.opts)
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
g.Expect(err).To(Equal(tt.wantErr))
|
||||||
|
g.Expect(got).To(BeNil())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
t.Errorf("AuthSecretStrategyForURL() got = %v, want %v", got, tt.want)
|
if tt.wantFunc != nil {
|
||||||
}
|
tt.wantFunc(g, got, tt.opts)
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBasicAuthStrategy_Method(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
secret corev1.Secret
|
|
||||||
modify func(secret *corev1.Secret)
|
|
||||||
want *git.Auth
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"username and password", basicAuthSecretFixture, nil, &git.Auth{AuthMethod: &http.BasicAuth{Username: "git", Password: "password"}}, false},
|
|
||||||
{"without username", basicAuthSecretFixture, func(s *corev1.Secret) { delete(s.Data, "username") }, nil, true},
|
|
||||||
{"without password", basicAuthSecretFixture, func(s *corev1.Secret) { delete(s.Data, "password") }, nil, true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
secret := tt.secret.DeepCopy()
|
|
||||||
if tt.modify != nil {
|
|
||||||
tt.modify(secret)
|
|
||||||
}
|
|
||||||
s := &BasicAuth{}
|
|
||||||
got, err := s.Method(*secret)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("Method() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
|
||||||
t.Errorf("Method() got = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPublicKeyStrategy_Method(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
secret corev1.Secret
|
|
||||||
modify func(secret *corev1.Secret)
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"private key and known_hosts", privateKeySecretFixture, nil, false},
|
|
||||||
{"private key with passphrase and known_hosts", privateKeySecretWithPassphraseFixture, nil, false},
|
|
||||||
{"edcsa private key with passphrase and known_hosts", failingPrivateKey, nil, false},
|
|
||||||
{"missing private key", privateKeySecretFixture, func(s *corev1.Secret) { delete(s.Data, "identity") }, true},
|
|
||||||
{"invalid private key", privateKeySecretFixture, func(s *corev1.Secret) { s.Data["identity"] = []byte(`-----BEGIN RSA PRIVATE KEY-----`) }, true},
|
|
||||||
{"missing known_hosts", privateKeySecretFixture, func(s *corev1.Secret) { delete(s.Data, "known_hosts") }, true},
|
|
||||||
{"invalid known_hosts", privateKeySecretFixture, func(s *corev1.Secret) { s.Data["known_hosts"] = []byte(`invalid`) }, true},
|
|
||||||
{"missing password", privateKeySecretWithPassphraseFixture, func(s *corev1.Secret) { delete(s.Data, "password") }, true},
|
|
||||||
{"wrong password", privateKeySecretWithPassphraseFixture, func(s *corev1.Secret) { s.Data["password"] = []byte("pass") }, true},
|
|
||||||
{"empty", corev1.Secret{}, nil, true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
secret := tt.secret.DeepCopy()
|
|
||||||
if tt.modify != nil {
|
|
||||||
tt.modify(secret)
|
|
||||||
}
|
|
||||||
s := &PublicKeyAuth{}
|
|
||||||
_, err := s.Method(*secret)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("Method() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,14 +58,11 @@ type CheckoutBranch struct {
|
||||||
branch string
|
branch string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CheckoutBranch) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) {
|
func (c *CheckoutBranch) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (git.Commit, string, error) {
|
||||||
repo, err := git2go.Clone(url, path, &git2go.CloneOptions{
|
repo, err := git2go.Clone(url, path, &git2go.CloneOptions{
|
||||||
FetchOptions: &git2go.FetchOptions{
|
FetchOptions: &git2go.FetchOptions{
|
||||||
DownloadTags: git2go.DownloadTagsNone,
|
DownloadTags: git2go.DownloadTagsNone,
|
||||||
RemoteCallbacks: git2go.RemoteCallbacks{
|
RemoteCallbacks: remoteCallbacks(opts),
|
||||||
CredentialsCallback: auth.CredCallback,
|
|
||||||
CertificateCheckCallback: auth.CertCallback,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
CheckoutBranch: c.branch,
|
CheckoutBranch: c.branch,
|
||||||
})
|
})
|
||||||
|
@ -88,14 +85,11 @@ type CheckoutTag struct {
|
||||||
tag string
|
tag string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CheckoutTag) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) {
|
func (c *CheckoutTag) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (git.Commit, string, error) {
|
||||||
repo, err := git2go.Clone(url, path, &git2go.CloneOptions{
|
repo, err := git2go.Clone(url, path, &git2go.CloneOptions{
|
||||||
FetchOptions: &git2go.FetchOptions{
|
FetchOptions: &git2go.FetchOptions{
|
||||||
DownloadTags: git2go.DownloadTagsAll,
|
DownloadTags: git2go.DownloadTagsAll,
|
||||||
RemoteCallbacks: git2go.RemoteCallbacks{
|
RemoteCallbacks: remoteCallbacks(opts),
|
||||||
CredentialsCallback: auth.CredCallback,
|
|
||||||
CertificateCheckCallback: auth.CertCallback,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -113,14 +107,11 @@ type CheckoutCommit struct {
|
||||||
commit string
|
commit string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CheckoutCommit) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) {
|
func (c *CheckoutCommit) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (git.Commit, string, error) {
|
||||||
repo, err := git2go.Clone(url, path, &git2go.CloneOptions{
|
repo, err := git2go.Clone(url, path, &git2go.CloneOptions{
|
||||||
FetchOptions: &git2go.FetchOptions{
|
FetchOptions: &git2go.FetchOptions{
|
||||||
DownloadTags: git2go.DownloadTagsNone,
|
DownloadTags: git2go.DownloadTagsNone,
|
||||||
RemoteCallbacks: git2go.RemoteCallbacks{
|
RemoteCallbacks: remoteCallbacks(opts),
|
||||||
CredentialsCallback: auth.CredCallback,
|
|
||||||
CertificateCheckCallback: auth.CertCallback,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -142,7 +133,7 @@ type CheckoutSemVer struct {
|
||||||
semVer string
|
semVer string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) {
|
func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (git.Commit, string, error) {
|
||||||
verConstraint, err := semver.NewConstraint(c.semVer)
|
verConstraint, err := semver.NewConstraint(c.semVer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("semver parse range error: %w", err)
|
return nil, "", fmt.Errorf("semver parse range error: %w", err)
|
||||||
|
@ -150,11 +141,8 @@ func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *g
|
||||||
|
|
||||||
repo, err := git2go.Clone(url, path, &git2go.CloneOptions{
|
repo, err := git2go.Clone(url, path, &git2go.CloneOptions{
|
||||||
FetchOptions: &git2go.FetchOptions{
|
FetchOptions: &git2go.FetchOptions{
|
||||||
DownloadTags: git2go.DownloadTagsAll,
|
DownloadTags: git2go.DownloadTagsAll,
|
||||||
RemoteCallbacks: git2go.RemoteCallbacks{
|
RemoteCallbacks: remoteCallbacks(opts),
|
||||||
CredentialsCallback: auth.CredCallback,
|
|
||||||
CertificateCheckCallback: auth.CertCallback,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -27,8 +27,6 @@ import (
|
||||||
|
|
||||||
git2go "github.com/libgit2/git2go/v31"
|
git2go "github.com/libgit2/git2go/v31"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
|
||||||
"github.com/fluxcd/source-controller/pkg/git"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCheckoutBranch_Checkout(t *testing.T) {
|
func TestCheckoutBranch_Checkout(t *testing.T) {
|
||||||
|
@ -84,7 +82,7 @@ func TestCheckoutBranch_Checkout(t *testing.T) {
|
||||||
tmpDir, _ := os.MkdirTemp("", "test")
|
tmpDir, _ := os.MkdirTemp("", "test")
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
_, ref, err := branch.Checkout(context.TODO(), tmpDir, repo.Path(), &git.Auth{})
|
_, ref, err := branch.Checkout(context.TODO(), tmpDir, repo.Path(), nil)
|
||||||
if tt.expectedErr != "" {
|
if tt.expectedErr != "" {
|
||||||
g.Expect(err.Error()).To(ContainSubstring(tt.expectedErr))
|
g.Expect(err.Error()).To(ContainSubstring(tt.expectedErr))
|
||||||
g.Expect(ref).To(BeEmpty())
|
g.Expect(ref).To(BeEmpty())
|
||||||
|
@ -154,7 +152,7 @@ func TestCheckoutTag_Checkout(t *testing.T) {
|
||||||
tmpDir, _ := os.MkdirTemp("", "test")
|
tmpDir, _ := os.MkdirTemp("", "test")
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
_, ref, err := tag.Checkout(context.TODO(), tmpDir, repo.Path(), &git.Auth{})
|
_, ref, err := tag.Checkout(context.TODO(), tmpDir, repo.Path(), nil)
|
||||||
if tt.expectErr != "" {
|
if tt.expectErr != "" {
|
||||||
g.Expect(err.Error()).To(Equal(tt.expectErr))
|
g.Expect(err.Error()).To(Equal(tt.expectErr))
|
||||||
g.Expect(ref).To(BeEmpty())
|
g.Expect(ref).To(BeEmpty())
|
||||||
|
@ -194,7 +192,7 @@ func TestCheckoutCommit_Checkout(t *testing.T) {
|
||||||
tmpDir, _ := os.MkdirTemp("", "git2go")
|
tmpDir, _ := os.MkdirTemp("", "git2go")
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
_, ref, err := commit.Checkout(context.TODO(), tmpDir, repo.Path(), &git.Auth{})
|
_, ref, err := commit.Checkout(context.TODO(), tmpDir, repo.Path(), nil)
|
||||||
g.Expect(err).To(BeNil())
|
g.Expect(err).To(BeNil())
|
||||||
g.Expect(ref).To(Equal("main/" + c.String()))
|
g.Expect(ref).To(Equal("main/" + c.String()))
|
||||||
g.Expect(filepath.Join(tmpDir, "commit")).To(BeARegularFile())
|
g.Expect(filepath.Join(tmpDir, "commit")).To(BeARegularFile())
|
||||||
|
@ -206,7 +204,7 @@ func TestCheckoutCommit_Checkout(t *testing.T) {
|
||||||
tmpDir2, _ := os.MkdirTemp("", "git2go")
|
tmpDir2, _ := os.MkdirTemp("", "git2go")
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
_, ref, err = commit.Checkout(context.TODO(), tmpDir2, repo.Path(), &git.Auth{})
|
_, ref, err = commit.Checkout(context.TODO(), tmpDir2, repo.Path(), nil)
|
||||||
g.Expect(err.Error()).To(HavePrefix("git checkout error: git commit '4dc3185c5fc94eb75048376edeb44571cece25f4' not found:"))
|
g.Expect(err.Error()).To(HavePrefix("git checkout error: git commit '4dc3185c5fc94eb75048376edeb44571cece25f4' not found:"))
|
||||||
g.Expect(ref).To(BeEmpty())
|
g.Expect(ref).To(BeEmpty())
|
||||||
}
|
}
|
||||||
|
@ -312,7 +310,7 @@ func TestCheckoutTagSemVer_Checkout(t *testing.T) {
|
||||||
tmpDir, _ := os.MkdirTemp("", "test")
|
tmpDir, _ := os.MkdirTemp("", "test")
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
_, ref, err := semVer.Checkout(context.TODO(), tmpDir, repo.Path(), &git.Auth{})
|
_, ref, err := semVer.Checkout(context.TODO(), tmpDir, repo.Path(), nil)
|
||||||
if tt.expectErr != nil {
|
if tt.expectErr != nil {
|
||||||
g.Expect(err).To(Equal(tt.expectErr))
|
g.Expect(err).To(Equal(tt.expectErr))
|
||||||
g.Expect(ref).To(BeEmpty())
|
g.Expect(ref).To(BeEmpty())
|
||||||
|
|
|
@ -23,140 +23,119 @@ import (
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"fmt"
|
|
||||||
"hash"
|
"hash"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
git2go "github.com/libgit2/git2go/v31"
|
git2go "github.com/libgit2/git2go/v31"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/crypto/ssh/knownhosts"
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
|
|
||||||
"github.com/fluxcd/source-controller/pkg/git"
|
"github.com/fluxcd/source-controller/pkg/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthSecretStrategyForURL(URL string) (git.AuthSecretStrategy, error) {
|
var (
|
||||||
u, err := url.Parse(URL)
|
now = time.Now
|
||||||
if err != nil {
|
)
|
||||||
return nil, fmt.Errorf("failed to parse URL to determine auth strategy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
// remoteCallbacks constructs RemoteCallbacks with credentialsCallback and
|
||||||
case u.Scheme == "http", u.Scheme == "https":
|
// certificateCallback, and the given options if the given opts is not nil.
|
||||||
return &BasicAuth{}, nil
|
func remoteCallbacks(opts *git.AuthOptions) git2go.RemoteCallbacks {
|
||||||
case u.Scheme == "ssh":
|
if opts != nil {
|
||||||
return &PublicKeyAuth{user: u.User.Username(), host: u.Host}, nil
|
return git2go.RemoteCallbacks{
|
||||||
default:
|
CredentialsCallback: credentialsCallback(opts),
|
||||||
return nil, fmt.Errorf("no auth secret strategy for scheme %s", u.Scheme)
|
CertificateCheckCallback: certificateCallback(opts),
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type BasicAuth struct{}
|
|
||||||
|
|
||||||
func (s *BasicAuth) Method(secret corev1.Secret) (*git.Auth, error) {
|
|
||||||
var credCallback git2go.CredentialsCallback
|
|
||||||
var username string
|
|
||||||
if d, ok := secret.Data["username"]; ok {
|
|
||||||
username = string(d)
|
|
||||||
}
|
|
||||||
var password string
|
|
||||||
if d, ok := secret.Data["password"]; ok {
|
|
||||||
password = string(d)
|
|
||||||
}
|
|
||||||
if username != "" && password != "" {
|
|
||||||
credCallback = func(url string, usernameFromURL string, allowedTypes git2go.CredentialType) (*git2go.Credential, error) {
|
|
||||||
cred, err := git2go.NewCredentialUserpassPlaintext(username, password)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cred, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return git2go.RemoteCallbacks{}
|
||||||
|
}
|
||||||
|
|
||||||
var certCallback git2go.CertificateCheckCallback
|
// credentialsCallback constructs CredentialsCallbacks with the given options
|
||||||
if caFile, ok := secret.Data[git.CAFile]; ok {
|
// for git.Transport if the given opts is not nil, and returns the result.
|
||||||
certCallback = func(cert *git2go.Certificate, valid bool, hostname string) git2go.ErrorCode {
|
func credentialsCallback(opts *git.AuthOptions) git2go.CredentialsCallback {
|
||||||
roots := x509.NewCertPool()
|
switch opts.Transport {
|
||||||
ok := roots.AppendCertsFromPEM(caFile)
|
case git.HTTP:
|
||||||
if !ok {
|
if opts.Username != "" {
|
||||||
return git2go.ErrorCodeCertificate
|
return func(u string, user string, allowedTypes git2go.CredentialType) (*git2go.Credential, error) {
|
||||||
|
return git2go.NewCredentialUsername(opts.Username)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
opts := x509.VerifyOptions{
|
case git.HTTPS:
|
||||||
Roots: roots,
|
if opts.Username != "" && opts.Password != "" {
|
||||||
DNSName: hostname,
|
return func(u string, user string, allowedTypes git2go.CredentialType) (*git2go.Credential, error) {
|
||||||
|
return git2go.NewCredentialUserpassPlaintext(opts.Username, opts.Password)
|
||||||
}
|
}
|
||||||
_, err := cert.X509.Verify(opts)
|
}
|
||||||
if err != nil {
|
case git.SSH:
|
||||||
return git2go.ErrorCodeCertificate
|
if len(opts.Identity) > 0 {
|
||||||
|
return func(u string, user string, allowedTypes git2go.CredentialType) (*git2go.Credential, error) {
|
||||||
|
return git2go.NewCredentialSSHKeyFromMemory(opts.Username, "", string(opts.Identity), opts.Password)
|
||||||
}
|
}
|
||||||
return git2go.ErrorCodeOK
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
return &git.Auth{CredCallback: credCallback, CertCallback: certCallback}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicKeyAuth struct {
|
// certificateCallback constructs CertificateCallback with the given options
|
||||||
user string
|
// for git.Transport if the given opts is not nil, and returns the result.
|
||||||
host string
|
func certificateCallback(opts *git.AuthOptions) git2go.CertificateCheckCallback {
|
||||||
|
switch opts.Transport {
|
||||||
|
case git.HTTPS:
|
||||||
|
if len(opts.CAFile) > 0 {
|
||||||
|
return x509Callback(opts.CAFile)
|
||||||
|
}
|
||||||
|
case git.SSH:
|
||||||
|
if len(opts.KnownHosts) > 0 && opts.Host != "" {
|
||||||
|
return knownHostsCallback(opts.Host, opts.KnownHosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PublicKeyAuth) Method(secret corev1.Secret) (*git.Auth, error) {
|
// x509Callback returns a CertificateCheckCallback that verifies the
|
||||||
if _, ok := secret.Data[git.CAFile]; ok {
|
// certificate against the given caBundle for git.HTTPS Transports.
|
||||||
return nil, fmt.Errorf("found %s key in secret '%s' but libgit2 SSH transport does not support custom certificates", git.CAFile, secret.Name)
|
func x509Callback(caBundle []byte) git2go.CertificateCheckCallback {
|
||||||
}
|
return func(cert *git2go.Certificate, valid bool, hostname string) git2go.ErrorCode {
|
||||||
identity := secret.Data["identity"]
|
roots := x509.NewCertPool()
|
||||||
knownHosts := secret.Data["known_hosts"]
|
if ok := roots.AppendCertsFromPEM(caBundle); !ok {
|
||||||
if len(identity) == 0 || len(knownHosts) == 0 {
|
return git2go.ErrorCodeCertificate
|
||||||
return nil, fmt.Errorf("invalid '%s' secret data: required fields 'identity' and 'known_hosts'", secret.Name)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
kk, err := parseKnownHosts(string(knownHosts))
|
opts := x509.VerifyOptions{
|
||||||
if err != nil {
|
Roots: roots,
|
||||||
return nil, err
|
DNSName: hostname,
|
||||||
|
CurrentTime: now(),
|
||||||
|
}
|
||||||
|
if _, err := cert.X509.Verify(opts); err != nil {
|
||||||
|
return git2go.ErrorCodeCertificate
|
||||||
|
}
|
||||||
|
return git2go.ErrorCodeOK
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Need to validate private key as it is not
|
// knownHostCallback returns a CertificateCheckCallback that verifies
|
||||||
// done by git2go when loading the key
|
// the key of Git server against the given host and known_hosts for
|
||||||
password, ok := secret.Data["password"]
|
// git.SSH Transports.
|
||||||
if ok {
|
func knownHostsCallback(host string, knownHosts []byte) git2go.CertificateCheckCallback {
|
||||||
_, err = ssh.ParsePrivateKeyWithPassphrase(identity, password)
|
return func(cert *git2go.Certificate, valid bool, hostname string) git2go.ErrorCode {
|
||||||
} else {
|
kh, err := parseKnownHosts(string(knownHosts))
|
||||||
_, err = ssh.ParsePrivateKey(identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user := s.user
|
|
||||||
if user == "" {
|
|
||||||
user = git.DefaultPublicKeyAuthUser
|
|
||||||
}
|
|
||||||
|
|
||||||
credCallback := func(url string, usernameFromURL string, allowedTypes git2go.CredentialType) (*git2go.Credential, error) {
|
|
||||||
cred, err := git2go.NewCredentialSSHKeyFromMemory(user, "", string(identity), string(password))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return git2go.ErrorCodeCertificate
|
||||||
}
|
}
|
||||||
return cred, nil
|
|
||||||
}
|
|
||||||
certCallback := func(cert *git2go.Certificate, valid bool, hostname string) git2go.ErrorCode {
|
|
||||||
// First, attempt to split the configured host and port to validate
|
// First, attempt to split the configured host and port to validate
|
||||||
// the port-less hostname given to the callback.
|
// the port-less hostname given to the callback.
|
||||||
host, _, err := net.SplitHostPort(s.host)
|
h, _, err := net.SplitHostPort(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// SplitHostPort returns an error if the host is missing
|
// SplitHostPort returns an error if the host is missing
|
||||||
// a port, assume the host has no port.
|
// a port, assume the host has no port.
|
||||||
host = s.host
|
h = host
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the configured host matches the hostname given to
|
// Check if the configured host matches the hostname given to
|
||||||
// the callback.
|
// the callback.
|
||||||
if host != hostname {
|
if h != hostname {
|
||||||
return git2go.ErrorCodeUser
|
return git2go.ErrorCodeUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,16 +143,14 @@ func (s *PublicKeyAuth) Method(secret corev1.Secret) (*git.Auth, error) {
|
||||||
// given to the callback match. Use the configured host (that
|
// given to the callback match. Use the configured host (that
|
||||||
// includes the port), and normalize it, so we can check if there
|
// includes the port), and normalize it, so we can check if there
|
||||||
// is an entry for the hostname _and_ port.
|
// is an entry for the hostname _and_ port.
|
||||||
host = knownhosts.Normalize(s.host)
|
h = knownhosts.Normalize(host)
|
||||||
for _, k := range kk {
|
for _, k := range kh {
|
||||||
if k.matches(host, cert.Hostkey) {
|
if k.matches(h, cert.Hostkey) {
|
||||||
return git2go.ErrorCodeOK
|
return git2go.ErrorCodeOK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return git2go.ErrorCodeCertificate
|
return git2go.ErrorCodeCertificate
|
||||||
}
|
}
|
||||||
|
|
||||||
return &git.Auth{CredCallback: credCallback, CertCallback: certCallback}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type knownKey struct {
|
type knownKey struct {
|
||||||
|
@ -234,6 +211,5 @@ func containsHost(hosts []string, host string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,163 +17,241 @@ limitations under the License.
|
||||||
package libgit2
|
package libgit2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"reflect"
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
git2go "github.com/libgit2/git2go/v31"
|
git2go "github.com/libgit2/git2go/v31"
|
||||||
corev1 "k8s.io/api/core/v1"
|
. "github.com/onsi/gomega"
|
||||||
|
|
||||||
"github.com/fluxcd/source-controller/pkg/git"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// secretKeyFixture is a randomly generated password less
|
geoTrustRootFixture = `-----BEGIN CERTIFICATE-----
|
||||||
// 512bit RSA private key.
|
MIIDVDCCAjygAwIBAgIDAjRWMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT
|
||||||
secretKeyFixture string = `-----BEGIN RSA PRIVATE KEY-----
|
MRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9i
|
||||||
MIICXAIBAAKBgQCrakELAKxozvwJijQEggYlTvS1QTZx1DaBwOhW/4kRSuR21plu
|
YWwgQ0EwHhcNMDIwNTIxMDQwMDAwWhcNMjIwNTIxMDQwMDAwWjBCMQswCQYDVQQG
|
||||||
xuQeyuUiztoWeb9jgW7wjzG4j1PIJjdbsgjPIcIZ4PBY7JeEW+QRopfwuN8MHXNp
|
EwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEbMBkGA1UEAxMSR2VvVHJ1c3Qg
|
||||||
uTLgIHbkmhoOg5qBEcjzO/lEOOPpV0EmbObgqv3+wRmLJrgfzWl/cTtRewIDAQAB
|
R2xvYmFsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2swYYzD9
|
||||||
AoGAawKFImpEN5Xn78iwWpQVZBsbV0AjzgHuGSiloxIZrorzf2DPHkHZzYNaclVx
|
9BcjGlZ+W988bDjkcbd4kdS8odhM+KhDtgPpTSEHCIjaWC9mOSm9BXiLnTjoBbdq
|
||||||
/o/4tBTsfg7WumH3qr541qyZJDgU7iRMABwmx0v1vm2wQiX7NJzLzH2E9vlMC3mw
|
fnGk5sRgprDvgOSJKA+eJdbtg/OtppHHmMlCGDUUna2YRpIuT8rxh0PBFpVXLVDv
|
||||||
d8S99g9EqRuNH98XX8su34B9WGRPqiKvEm0RW8Hideo2/KkCQQDbs6rHcriKQyPB
|
iS2Aelet8u5fa9IAjbkU+BQVNdnARqN7csiRv8lVK83Qlz6cJmTM386DGXHKTubU
|
||||||
paidHZAfguu0eVbyHT2EgLgRboWE+tEAqFEW2ycqNL3VPz9fRvwexbB6rpOcPpQJ
|
1XupGc1V3sjs0l44U+VcT4wt/lAjNvxm5suOpDkZALeVAjmRCw7+OC7RHQWa9k0+
|
||||||
DEL4XB2XAkEAx7xJz8YlCQ2H38xggK8R8EUXF9Zhb0fqMJHMNmao1HCHVMtbsa8I
|
bw8HHa8sHo9gOeL6NlMTOdReJivbPagUvTLrGAMoUgRx5aszPeE4uwc2hGKceeoW
|
||||||
jR2EGyQ4CaIqNG5tdWukXQSJrPYDRWNvvQJAZX3rP7XUYDLB2twvN12HzbbKMhX3
|
MPRfwCvocWvk+QIDAQABo1MwUTAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTA
|
||||||
v2MYnxRjc9INpi/Dyzz2MMvOnOW+aDuOh/If2AtVCmeJUx1pf4CFk3viQwJBAKyC
|
ephojYn7qwVkDBF9qn1luMrMTjAfBgNVHSMEGDAWgBTAephojYn7qwVkDBF9qn1l
|
||||||
t824+evjv+NQBlme3AOF6PgxtV4D4wWoJ5Uk/dTejER0j/Hbl6sqPxuiILRRV9qJ
|
uMrMTjANBgkqhkiG9w0BAQUFAAOCAQEANeMpauUvXVSOKVCUn5kaFOSPeCpilKIn
|
||||||
Ngkgu4mLjc3RfenEhJECQAx8zjWUE6kHHPGAd9DfiAIQ4bChqnyS0Nwb9+Gd4hSE
|
Z57QzxpeR+nBsqTP3UEaBU6bS+5Kb1VSsyShNwrrZHYqLizz/Tt1kL/6cdjHPTfS
|
||||||
P0Ah10mHiK/M0o3T8Eanwum0gbQHPnOwqZgsPkwXRqQ=
|
tQWVYrmm3ok9Nns4d0iXrKYgjy6myQzCsplFAMfOEVEiIuCl6rYVSAlk6l5PdPcF
|
||||||
-----END RSA PRIVATE KEY-----`
|
PseKUgzbFbS9bZvlxrFUaKnjaZC2mqUPuLk/IH2uSrW4nOQdtqvmlKXBx4Ot2/Un
|
||||||
|
hw4EbNX/3aBd7YdStysVAq45pmp06drE57xNNB6pXE0zX5IJL4hmXXeXxx12E6nV
|
||||||
|
5fEWCRE11azbJHFwLJhWC9kXtNHjUStedejV0NxPNO3CBWaAocvmMw==
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
// secretKeyFixture is a randomly generated
|
giag2IntermediateFixture = `-----BEGIN CERTIFICATE-----
|
||||||
// 512bit RSA private key with password foobar.
|
MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT
|
||||||
secretPassphraseFixture = `-----BEGIN RSA PRIVATE KEY-----
|
MRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9i
|
||||||
Proc-Type: 4,ENCRYPTED
|
YWwgQ0EwHhcNMTMwNDA1MTUxNTU1WhcNMTUwNDA0MTUxNTU1WjBJMQswCQYDVQQG
|
||||||
DEK-Info: AES-256-CBC,0B016973B2A761D31E6B388D0F327C35
|
EwJVUzETMBEGA1UEChMKR29vZ2xlIEluYzElMCMGA1UEAxMcR29vZ2xlIEludGVy
|
||||||
|
bmV0IEF1dGhvcml0eSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||||
|
AJwqBHdc2FCROgajguDYUEi8iT/xGXAaiEZ+4I/F8YnOIe5a/mENtzJEiaB0C1NP
|
||||||
|
VaTOgmKV7utZX8bhBYASxF6UP7xbSDj0U/ck5vuR6RXEz/RTDfRK/J9U3n2+oGtv
|
||||||
|
h8DQUB8oMANA2ghzUWx//zo8pzcGjr1LEQTrfSTe5vn8MXH7lNVg8y5Kr0LSy+rE
|
||||||
|
ahqyzFPdFUuLH8gZYR/Nnag+YyuENWllhMgZxUYi+FOVvuOAShDGKuy6lyARxzmZ
|
||||||
|
EASg8GF6lSWMTlJ14rbtCMoU/M4iarNOz0YDl5cDfsCx3nuvRTPPuj5xt970JSXC
|
||||||
|
DTWJnZ37DhF5iR43xa+OcmkCAwEAAaOB+zCB+DAfBgNVHSMEGDAWgBTAephojYn7
|
||||||
|
qwVkDBF9qn1luMrMTjAdBgNVHQ4EFgQUSt0GFhu89mi1dvWBtrtiGrpagS8wEgYD
|
||||||
|
VR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAQYwOgYDVR0fBDMwMTAvoC2g
|
||||||
|
K4YpaHR0cDovL2NybC5nZW90cnVzdC5jb20vY3Jscy9ndGdsb2JhbC5jcmwwPQYI
|
||||||
|
KwYBBQUHAQEEMTAvMC0GCCsGAQUFBzABhiFodHRwOi8vZ3RnbG9iYWwtb2NzcC5n
|
||||||
|
ZW90cnVzdC5jb20wFwYDVR0gBBAwDjAMBgorBgEEAdZ5AgUBMA0GCSqGSIb3DQEB
|
||||||
|
BQUAA4IBAQA21waAESetKhSbOHezI6B1WLuxfoNCunLaHtiONgaX4PCVOzf9G0JY
|
||||||
|
/iLIa704XtE7JW4S615ndkZAkNoUyHgN7ZVm2o6Gb4ChulYylYbc3GrKBIxbf/a/
|
||||||
|
zG+FA1jDaFETzf3I93k9mTXwVqO94FntT0QJo544evZG0R0SnU++0ED8Vf4GXjza
|
||||||
|
HFa9llF7b1cq26KqltyMdMKVvvBulRP/F/A8rLIQjcxz++iPAsbw+zOzlTvjwsto
|
||||||
|
WHPbqCRiOwY1nQ2pM714A5AuTHhdUDqB1O6gyHA43LL5Z/qHQF1hwFGPa4NrzQU6
|
||||||
|
yuGnBXj8ytqU0CwIPX4WecigUCAkVDNx
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
X9GET/qAyZkAJBl/RK+1XX75NxONgdUfZDw7PIYi/g+Efh3Z5zH5kh/dx9lxH5ZG
|
googleLeafFixture = `-----BEGIN CERTIFICATE-----
|
||||||
HGCqPAeMO/ofGDGtDULWW6iqDUFRu5gPgEVSCnnbqoHNU325WHhXdhejVAItwObC
|
MIIEdjCCA16gAwIBAgIIcR5k4dkoe04wDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE
|
||||||
IpL/zYfs2+gDHXct/n9FJ/9D/EGXZihwPqYaK8GQSfZAxz0QjLuh0wU1qpbm3y3N
|
BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl
|
||||||
q+o9FLv3b2Ys/tCJOUsYVQOYLSrZEI77y1ii3nWgQ8lXiTJbBUKzuq4f1YWeO8Ah
|
cm5ldCBBdXRob3JpdHkgRzIwHhcNMTQwMzEyMDkzODMwWhcNMTQwNjEwMDAwMDAw
|
||||||
RZbdhTa57AF5lUaRtL7Nrm3HJUrK1alBbU7HHyjeW4Q4n/D3fiRDC1Mh2Bi4EOOn
|
WjBoMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwN
|
||||||
wGctSx4kHsZGhJv5qwKqqPEFPhUzph8D2tm2TABk8HJa5KJFDbGrcfvk2uODAoZr
|
TW91bnRhaW4gVmlldzETMBEGA1UECgwKR29vZ2xlIEluYzEXMBUGA1UEAwwOd3d3
|
||||||
MbcpIxCfl8oB09bWfY6tDQjyvwSYYo2Phdwm7kT92xc=
|
Lmdvb2dsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4zYCe
|
||||||
-----END RSA PRIVATE KEY-----`
|
m0oUBhwE0EwBr65eBOcgcQO2PaSIAB2dEP/c1EMX2tOy0ov8rk83ePhJ+MWdT1z6
|
||||||
|
jge9X4zQQI8ZyA9qIiwrKBZOi8DNUvrqNZC7fJAVRrb9aX/99uYOJCypIbpmWG1q
|
||||||
|
fhbHjJewhwf8xYPj71eU4rLG80a+DapWmphtfq3h52lDQIBzLVf1yYbyrTaELaz4
|
||||||
|
NXF7HXb5YkId/gxIsSzM0aFUVu2o8sJcLYAsJqwfFKBKOMxUcn545nlspf0mTcWZ
|
||||||
|
0APlbwsKznNs4/xCDwIxxWjjqgHrYAFl6y07i1gzbAOqdNEyR24p+3JWI8WZBlBI
|
||||||
|
dk2KGj0W1fIfsvyxAgMBAAGjggFBMIIBPTAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
|
||||||
|
KwYBBQUHAwIwGQYDVR0RBBIwEIIOd3d3Lmdvb2dsZS5jb20waAYIKwYBBQUHAQEE
|
||||||
|
XDBaMCsGCCsGAQUFBzAChh9odHRwOi8vcGtpLmdvb2dsZS5jb20vR0lBRzIuY3J0
|
||||||
|
MCsGCCsGAQUFBzABhh9odHRwOi8vY2xpZW50czEuZ29vZ2xlLmNvbS9vY3NwMB0G
|
||||||
|
A1UdDgQWBBTXD5Bx6iqT+dmEhbFL4OUoHyZn8zAMBgNVHRMBAf8EAjAAMB8GA1Ud
|
||||||
|
IwQYMBaAFErdBhYbvPZotXb1gba7Yhq6WoEvMBcGA1UdIAQQMA4wDAYKKwYBBAHW
|
||||||
|
eQIFATAwBgNVHR8EKTAnMCWgI6Ahhh9odHRwOi8vcGtpLmdvb2dsZS5jb20vR0lB
|
||||||
|
RzIuY3JsMA0GCSqGSIb3DQEBBQUAA4IBAQCR3RJtHzgDh33b/MI1ugiki+nl8Ikj
|
||||||
|
5larbJRE/rcA5oite+QJyAr6SU1gJJ/rRrK3ItVEHr9L621BCM7GSdoNMjB9MMcf
|
||||||
|
tJAW0kYGJ+wqKm53wG/JaOADTnnq2Mt/j6F2uvjgN/ouns1nRHufIvd370N0LeH+
|
||||||
|
orKqTuAPzXK7imQk6+OycYABbqCtC/9qmwRd8wwn7sF97DtYfK8WuNHtFalCAwyi
|
||||||
|
8LxJJYJCLWoMhZ+V8GZm+FOex5qkQAjnZrtNlbQJ8ro4r+rpKXtmMFFhfa+7L+PA
|
||||||
|
Kom08eUK8skxAzfDDijZPh10VtJ66uBoiDPdT+uCBehcBIcmSTrKjFGX
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
// knownHostsFixture is known_hosts fixture in the expected
|
// googleLeafWithInvalidHashFixture is the same as googleLeafFixture, but the signature
|
||||||
// format.
|
// algorithm in the certificate contains a nonsense OID.
|
||||||
knownHostsFixture string = `github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==`
|
googleLeafWithInvalidHashFixture = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEdjCCA16gAwIBAgIIcR5k4dkoe04wDQYJKoZIhvcNAWAFBQAwSTELMAkGA1UE
|
||||||
|
BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl
|
||||||
|
cm5ldCBBdXRob3JpdHkgRzIwHhcNMTQwMzEyMDkzODMwWhcNMTQwNjEwMDAwMDAw
|
||||||
|
WjBoMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwN
|
||||||
|
TW91bnRhaW4gVmlldzETMBEGA1UECgwKR29vZ2xlIEluYzEXMBUGA1UEAwwOd3d3
|
||||||
|
Lmdvb2dsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC4zYCe
|
||||||
|
m0oUBhwE0EwBr65eBOcgcQO2PaSIAB2dEP/c1EMX2tOy0ov8rk83ePhJ+MWdT1z6
|
||||||
|
jge9X4zQQI8ZyA9qIiwrKBZOi8DNUvrqNZC7fJAVRrb9aX/99uYOJCypIbpmWG1q
|
||||||
|
fhbHjJewhwf8xYPj71eU4rLG80a+DapWmphtfq3h52lDQIBzLVf1yYbyrTaELaz4
|
||||||
|
NXF7HXb5YkId/gxIsSzM0aFUVu2o8sJcLYAsJqwfFKBKOMxUcn545nlspf0mTcWZ
|
||||||
|
0APlbwsKznNs4/xCDwIxxWjjqgHrYAFl6y07i1gzbAOqdNEyR24p+3JWI8WZBlBI
|
||||||
|
dk2KGj0W1fIfsvyxAgMBAAGjggFBMIIBPTAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
|
||||||
|
KwYBBQUHAwIwGQYDVR0RBBIwEIIOd3d3Lmdvb2dsZS5jb20waAYIKwYBBQUHAQEE
|
||||||
|
XDBaMCsGCCsGAQUFBzAChh9odHRwOi8vcGtpLmdvb2dsZS5jb20vR0lBRzIuY3J0
|
||||||
|
MCsGCCsGAQUFBzABhh9odHRwOi8vY2xpZW50czEuZ29vZ2xlLmNvbS9vY3NwMB0G
|
||||||
|
A1UdDgQWBBTXD5Bx6iqT+dmEhbFL4OUoHyZn8zAMBgNVHRMBAf8EAjAAMB8GA1Ud
|
||||||
|
IwQYMBaAFErdBhYbvPZotXb1gba7Yhq6WoEvMBcGA1UdIAQQMA4wDAYKKwYBBAHW
|
||||||
|
eQIFATAwBgNVHR8EKTAnMCWgI6Ahhh9odHRwOi8vcGtpLmdvb2dsZS5jb20vR0lB
|
||||||
|
RzIuY3JsMA0GCSqGSIb3DQFgBQUAA4IBAQCR3RJtHzgDh33b/MI1ugiki+nl8Ikj
|
||||||
|
5larbJRE/rcA5oite+QJyAr6SU1gJJ/rRrK3ItVEHr9L621BCM7GSdoNMjB9MMcf
|
||||||
|
tJAW0kYGJ+wqKm53wG/JaOADTnnq2Mt/j6F2uvjgN/ouns1nRHufIvd370N0LeH+
|
||||||
|
orKqTuAPzXK7imQk6+OycYABbqCtC/9qmwRd8wwn7sF97DtYfK8WuNHtFalCAwyi
|
||||||
|
8LxJJYJCLWoMhZ+V8GZm+FOex5qkQAjnZrtNlbQJ8ro4r+rpKXtmMFFhfa+7L+PA
|
||||||
|
Kom08eUK8skxAzfDDijZPh10VtJ66uBoiDPdT+uCBehcBIcmSTrKjFGX
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
|
knownHosts string = `github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==`
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
func Test_x509Callback(t *testing.T) {
|
||||||
basicAuthSecretFixture = corev1.Secret{
|
now = func() time.Time { return time.Unix(1395785200, 0) }
|
||||||
Data: map[string][]byte{
|
|
||||||
"username": []byte("git"),
|
|
||||||
"password": []byte("password"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
privateKeySecretFixture = corev1.Secret{
|
|
||||||
Data: map[string][]byte{
|
|
||||||
"identity": []byte(secretKeyFixture),
|
|
||||||
"known_hosts": []byte(knownHostsFixture),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
privateKeySecretWithPassphraseFixture = corev1.Secret{
|
|
||||||
Data: map[string][]byte{
|
|
||||||
"identity": []byte(secretPassphraseFixture),
|
|
||||||
"known_hosts": []byte(knownHostsFixture),
|
|
||||||
"password": []byte("foobar"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAuthSecretStrategyForURL(t *testing.T) {
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
url string
|
certificate string
|
||||||
want git.AuthSecretStrategy
|
host string
|
||||||
wantErr bool
|
caBundle []byte
|
||||||
|
want git2go.ErrorCode
|
||||||
}{
|
}{
|
||||||
{"HTTP", "http://git.example.com/org/repo.git", &BasicAuth{}, false},
|
{
|
||||||
{"HTTPS", "https://git.example.com/org/repo.git", &BasicAuth{}, false},
|
name: "Valid certificate authority bundle",
|
||||||
{"SSH", "ssh://git.example.com:2222/org/repo.git", &PublicKeyAuth{host: "git.example.com:2222"}, false},
|
certificate: googleLeafFixture,
|
||||||
{"SSH with username", "ssh://example@git.example.com:2222/org/repo.git", &PublicKeyAuth{user: "example", host: "git.example.com:2222"}, false},
|
host: "www.google.com",
|
||||||
{"unsupported", "protocol://example.com", nil, true},
|
caBundle: []byte(giag2IntermediateFixture + "\n" + geoTrustRootFixture),
|
||||||
|
want: git2go.ErrorCodeOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid certificate",
|
||||||
|
certificate: googleLeafWithInvalidHashFixture,
|
||||||
|
host: "www.google.com",
|
||||||
|
caBundle: []byte(giag2IntermediateFixture + "\n" + geoTrustRootFixture),
|
||||||
|
want: git2go.ErrorCodeCertificate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid certificate authority bundle",
|
||||||
|
certificate: googleLeafFixture,
|
||||||
|
host: "www.google.com",
|
||||||
|
caBundle: bytes.Trim([]byte(giag2IntermediateFixture+"\n"+geoTrustRootFixture), "-"),
|
||||||
|
want: git2go.ErrorCodeCertificate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing intermediate in bundle",
|
||||||
|
certificate: googleLeafFixture,
|
||||||
|
host: "www.google.com",
|
||||||
|
caBundle: []byte(geoTrustRootFixture),
|
||||||
|
want: git2go.ErrorCodeCertificate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid host",
|
||||||
|
certificate: googleLeafFixture,
|
||||||
|
host: "www.google.co",
|
||||||
|
caBundle: []byte(giag2IntermediateFixture + "\n" + geoTrustRootFixture),
|
||||||
|
want: git2go.ErrorCodeCertificate,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := AuthSecretStrategyForURL(tt.url)
|
g := NewWithT(t)
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("AuthSecretStrategyForURL() error = %v, wantErr %v", err, tt.wantErr)
|
cert := &git2go.Certificate{}
|
||||||
return
|
if tt.certificate != "" {
|
||||||
}
|
x509Cert, err := certificateFromPEM(tt.certificate)
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
g.Expect(err).ToNot(HaveOccurred())
|
||||||
t.Errorf("AuthSecretStrategyForURL() got = %v, want %v", got, tt.want)
|
cert.X509 = x509Cert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
callback := x509Callback(tt.caBundle)
|
||||||
|
g.Expect(callback(cert, false, tt.host)).To(Equal(tt.want))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBasicAuthStrategy_Method(t *testing.T) {
|
func Test_knownHostsCallback(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
secret corev1.Secret
|
host string
|
||||||
modify func(secret *corev1.Secret)
|
expectedHost string
|
||||||
wantErr bool
|
knownHosts []byte
|
||||||
|
hostkey git2go.HostkeyCertificate
|
||||||
|
want git2go.ErrorCode
|
||||||
}{
|
}{
|
||||||
{"with username and password", basicAuthSecretFixture, nil, false},
|
{
|
||||||
|
name: "Match",
|
||||||
|
host: "github.com",
|
||||||
|
knownHosts: []byte(knownHosts),
|
||||||
|
hostkey: git2go.HostkeyCertificate{Kind: git2go.HostkeySHA1 | git2go.HostkeyMD5, HashSHA1: sha1Fingerprint("v2toJdKXfFEaR1u++4iq1UqSrHM")},
|
||||||
|
expectedHost: "github.com",
|
||||||
|
want: git2go.ErrorCodeOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Match with port",
|
||||||
|
host: "github.com",
|
||||||
|
knownHosts: []byte(knownHosts),
|
||||||
|
hostkey: git2go.HostkeyCertificate{Kind: git2go.HostkeySHA1 | git2go.HostkeyMD5, HashSHA1: sha1Fingerprint("v2toJdKXfFEaR1u++4iq1UqSrHM")},
|
||||||
|
expectedHost: "github.com:22",
|
||||||
|
want: git2go.ErrorCodeOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hostname mismatch",
|
||||||
|
host: "github.com",
|
||||||
|
knownHosts: []byte(knownHosts),
|
||||||
|
hostkey: git2go.HostkeyCertificate{Kind: git2go.HostkeySHA1 | git2go.HostkeyMD5, HashSHA1: sha1Fingerprint("v2toJdKXfFEaR1u++4iq1UqSrHM")},
|
||||||
|
expectedHost: "example.com",
|
||||||
|
want: git2go.ErrorCodeUser,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hostkey mismatch",
|
||||||
|
host: "github.com",
|
||||||
|
knownHosts: []byte(knownHosts),
|
||||||
|
hostkey: git2go.HostkeyCertificate{Kind: git2go.HostkeyMD5, HashMD5: md5Fingerprint("\xb6\x03\x0e\x39\x97\x9e\xd0\xe7\x24\xce\xa3\x77\x3e\x01\x42\x09")},
|
||||||
|
expectedHost: "github.com",
|
||||||
|
want: git2go.ErrorCodeCertificate,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
secret := tt.secret.DeepCopy()
|
g := NewWithT(t)
|
||||||
if tt.modify != nil {
|
|
||||||
tt.modify(secret)
|
cert := &git2go.Certificate{Hostkey: tt.hostkey}
|
||||||
}
|
callback := knownHostsCallback(tt.expectedHost, tt.knownHosts)
|
||||||
s := &BasicAuth{}
|
g.Expect(callback(cert, false, tt.host)).To(Equal(tt.want))
|
||||||
_, err := s.Method(*secret)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("Method() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicKeyStrategy_Method(t *testing.T) {
|
func Test_parseKnownHosts(t *testing.T) {
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
secret corev1.Secret
|
|
||||||
modify func(secret *corev1.Secret)
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"private key and known_hosts", privateKeySecretFixture, nil, false},
|
|
||||||
{"private key with passphrase and known_hosts", privateKeySecretWithPassphraseFixture, nil, false},
|
|
||||||
{"missing private key", privateKeySecretFixture, func(s *corev1.Secret) { delete(s.Data, "identity") }, true},
|
|
||||||
{"invalid private key", privateKeySecretFixture, func(s *corev1.Secret) { s.Data["identity"] = []byte(`-----BEGIN RSA PRIVATE KEY-----`) }, true},
|
|
||||||
{"missing known_hosts", privateKeySecretFixture, func(s *corev1.Secret) { delete(s.Data, "known_hosts") }, true},
|
|
||||||
{"invalid known_hosts", privateKeySecretFixture, func(s *corev1.Secret) { s.Data["known_hosts"] = []byte(`invalid`) }, true},
|
|
||||||
{"missing password", privateKeySecretWithPassphraseFixture, func(s *corev1.Secret) { delete(s.Data, "password") }, true},
|
|
||||||
{"invalid password", privateKeySecretWithPassphraseFixture, func(s *corev1.Secret) { s.Data["password"] = []byte("foo") }, true},
|
|
||||||
{"empty", corev1.Secret{}, nil, true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
secret := tt.secret.DeepCopy()
|
|
||||||
if tt.modify != nil {
|
|
||||||
tt.modify(secret)
|
|
||||||
}
|
|
||||||
s := &PublicKeyAuth{}
|
|
||||||
_, err := s.Method(*secret)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("Method() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKnownKeyHash(t *testing.T) {
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
hostkey git2go.HostkeyCertificate
|
hostkey git2go.HostkeyCertificate
|
||||||
|
@ -189,24 +267,22 @@ func TestKnownKeyHash(t *testing.T) {
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
knownKeys, err := parseKnownHosts(knownHostsFixture)
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
knownKeys, err := parseKnownHosts(knownHosts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := knownKeys[0].matches("github.com", tt.hostkey)
|
matches := knownKeys[0].matches("github.com", tt.hostkey)
|
||||||
if matches != tt.wantMatches {
|
g.Expect(matches).To(Equal(tt.wantMatches))
|
||||||
t.Errorf("Method() matches = %v, wantMatches %v", matches, tt.wantMatches)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func md5Fingerprint(in string) [16]byte {
|
func md5Fingerprint(in string) [16]byte {
|
||||||
var out [16]byte
|
var out [16]byte
|
||||||
copy(out[:], []byte(in))
|
copy(out[:], in)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -229,3 +305,11 @@ func sha256Fingerprint(in string) [32]byte {
|
||||||
copy(out[:], d)
|
copy(out[:], d)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func certificateFromPEM(pemBytes string) (*x509.Certificate, error) {
|
||||||
|
block, _ := pem.Decode([]byte(pemBytes))
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("failed to decode PEM")
|
||||||
|
}
|
||||||
|
return x509.ParseCertificate(block.Bytes)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
v1 "k8s.io/api/core/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultOrigin = "origin"
|
||||||
|
DefaultBranch = "master"
|
||||||
|
DefaultPublicKeyAuthUser = "git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckoutOptions are the options used for a Git checkout.
|
||||||
|
type CheckoutOptions struct {
|
||||||
|
GitImplementation string
|
||||||
|
RecurseSubmodules bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransportType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SSH TransportType = "ssh"
|
||||||
|
HTTPS TransportType = "https"
|
||||||
|
HTTP TransportType = "http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthOptions are the authentication options for the Transport of
|
||||||
|
// communication with a remote origin.
|
||||||
|
type AuthOptions struct {
|
||||||
|
Transport TransportType
|
||||||
|
Host string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Identity []byte
|
||||||
|
KnownHosts []byte
|
||||||
|
CAFile []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the AuthOptions against the defined Transport.
|
||||||
|
func (o AuthOptions) Validate() error {
|
||||||
|
switch o.Transport {
|
||||||
|
case HTTPS, HTTP:
|
||||||
|
if o.Username == "" && o.Password != "" {
|
||||||
|
return fmt.Errorf("invalid '%s' auth option: 'password' requires 'username' to be set", o.Transport)
|
||||||
|
}
|
||||||
|
case SSH:
|
||||||
|
if len(o.Identity) == 0 {
|
||||||
|
return fmt.Errorf("invalid '%s' auth option: 'identity' is required", o.Transport)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if o.Password != "" {
|
||||||
|
_, err = ssh.ParsePrivateKeyWithPassphrase(o.Identity, []byte(o.Password))
|
||||||
|
} else {
|
||||||
|
_, err = ssh.ParsePrivateKey(o.Identity)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid '%s' auth option 'identity': %w", o.Transport, err)
|
||||||
|
}
|
||||||
|
if len(o.KnownHosts) == 0 {
|
||||||
|
return fmt.Errorf("invalid '%s' auth option: 'known_hosts' is required", o.Transport)
|
||||||
|
}
|
||||||
|
case "":
|
||||||
|
return fmt.Errorf("no transport type set")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthOptionsFromSecret constructs an AuthOptions object from the given Secret,
|
||||||
|
// and then validates the result. It returns the AuthOptions, or an error.
|
||||||
|
func AuthOptionsFromSecret(URL string, secret *v1.Secret) (*AuthOptions, error) {
|
||||||
|
if secret == nil {
|
||||||
|
return nil, fmt.Errorf("no secret provided to construct auth strategy from")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse URL to determine auth strategy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &AuthOptions{
|
||||||
|
Transport: TransportType(u.Scheme),
|
||||||
|
Host: u.Host,
|
||||||
|
Username: string(secret.Data["username"]),
|
||||||
|
Password: string(secret.Data["password"]),
|
||||||
|
CAFile: secret.Data["caFile"],
|
||||||
|
Identity: secret.Data["identity"],
|
||||||
|
KnownHosts: secret.Data["known_hosts"],
|
||||||
|
}
|
||||||
|
if opts.Username == "" {
|
||||||
|
opts.Username = u.User.Username()
|
||||||
|
}
|
||||||
|
if opts.Username == "" {
|
||||||
|
opts.Username = DefaultPublicKeyAuthUser
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = opts.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
|
@ -0,0 +1,236 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 The Flux authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
v1 "k8s.io/api/core/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// privateKeyFixture is a randomly generated password less
|
||||||
|
// 512bit RSA private key.
|
||||||
|
privateKeyFixture = `-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIICXAIBAAKBgQCrakELAKxozvwJijQEggYlTvS1QTZx1DaBwOhW/4kRSuR21plu
|
||||||
|
xuQeyuUiztoWeb9jgW7wjzG4j1PIJjdbsgjPIcIZ4PBY7JeEW+QRopfwuN8MHXNp
|
||||||
|
uTLgIHbkmhoOg5qBEcjzO/lEOOPpV0EmbObgqv3+wRmLJrgfzWl/cTtRewIDAQAB
|
||||||
|
AoGAawKFImpEN5Xn78iwWpQVZBsbV0AjzgHuGSiloxIZrorzf2DPHkHZzYNaclVx
|
||||||
|
/o/4tBTsfg7WumH3qr541qyZJDgU7iRMABwmx0v1vm2wQiX7NJzLzH2E9vlMC3mw
|
||||||
|
d8S99g9EqRuNH98XX8su34B9WGRPqiKvEm0RW8Hideo2/KkCQQDbs6rHcriKQyPB
|
||||||
|
paidHZAfguu0eVbyHT2EgLgRboWE+tEAqFEW2ycqNL3VPz9fRvwexbB6rpOcPpQJ
|
||||||
|
DEL4XB2XAkEAx7xJz8YlCQ2H38xggK8R8EUXF9Zhb0fqMJHMNmao1HCHVMtbsa8I
|
||||||
|
jR2EGyQ4CaIqNG5tdWukXQSJrPYDRWNvvQJAZX3rP7XUYDLB2twvN12HzbbKMhX3
|
||||||
|
v2MYnxRjc9INpi/Dyzz2MMvOnOW+aDuOh/If2AtVCmeJUx1pf4CFk3viQwJBAKyC
|
||||||
|
t824+evjv+NQBlme3AOF6PgxtV4D4wWoJ5Uk/dTejER0j/Hbl6sqPxuiILRRV9qJ
|
||||||
|
Ngkgu4mLjc3RfenEhJECQAx8zjWUE6kHHPGAd9DfiAIQ4bChqnyS0Nwb9+Gd4hSE
|
||||||
|
P0Ah10mHiK/M0o3T8Eanwum0gbQHPnOwqZgsPkwXRqQ=
|
||||||
|
-----END RSA PRIVATE KEY-----`
|
||||||
|
|
||||||
|
// privateKeyPassphraseFixture is a randomly generated
|
||||||
|
// 512bit RSA private key with password foobar.
|
||||||
|
privateKeyPassphraseFixture = `-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
Proc-Type: 4,ENCRYPTED
|
||||||
|
DEK-Info: AES-256-CBC,0B016973B2A761D31E6B388D0F327C35
|
||||||
|
|
||||||
|
X9GET/qAyZkAJBl/RK+1XX75NxONgdUfZDw7PIYi/g+Efh3Z5zH5kh/dx9lxH5ZG
|
||||||
|
HGCqPAeMO/ofGDGtDULWW6iqDUFRu5gPgEVSCnnbqoHNU325WHhXdhejVAItwObC
|
||||||
|
IpL/zYfs2+gDHXct/n9FJ/9D/EGXZihwPqYaK8GQSfZAxz0QjLuh0wU1qpbm3y3N
|
||||||
|
q+o9FLv3b2Ys/tCJOUsYVQOYLSrZEI77y1ii3nWgQ8lXiTJbBUKzuq4f1YWeO8Ah
|
||||||
|
RZbdhTa57AF5lUaRtL7Nrm3HJUrK1alBbU7HHyjeW4Q4n/D3fiRDC1Mh2Bi4EOOn
|
||||||
|
wGctSx4kHsZGhJv5qwKqqPEFPhUzph8D2tm2TABk8HJa5KJFDbGrcfvk2uODAoZr
|
||||||
|
MbcpIxCfl8oB09bWfY6tDQjyvwSYYo2Phdwm7kT92xc=
|
||||||
|
-----END RSA PRIVATE KEY-----`
|
||||||
|
|
||||||
|
// knownHostsFixture is known_hosts fixture in the expected
|
||||||
|
// format.
|
||||||
|
knownHostsFixture = `github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==`
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthOptions_Validate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts AuthOptions
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "HTTP transport with password requires user",
|
||||||
|
opts: AuthOptions{
|
||||||
|
Transport: HTTP,
|
||||||
|
Password: "foo",
|
||||||
|
},
|
||||||
|
wantErr: "invalid 'http' auth option: 'password' requires 'username' to be set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTPS transport with password requires user",
|
||||||
|
opts: AuthOptions{
|
||||||
|
Transport: HTTPS,
|
||||||
|
Password: "foo",
|
||||||
|
},
|
||||||
|
wantErr: "invalid 'https' auth option: 'password' requires 'username' to be set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH transport requires identity",
|
||||||
|
opts: AuthOptions{
|
||||||
|
Transport: SSH,
|
||||||
|
},
|
||||||
|
wantErr: "invalid 'ssh' auth option: 'identity' is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH transport requires valid identity",
|
||||||
|
opts: AuthOptions{
|
||||||
|
Transport: SSH,
|
||||||
|
Identity: []byte("malformed"),
|
||||||
|
},
|
||||||
|
wantErr: "invalid 'ssh' auth option 'identity': ssh: no key found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH transport requires valid identity password",
|
||||||
|
opts: AuthOptions{
|
||||||
|
Transport: SSH,
|
||||||
|
Identity: []byte(privateKeyPassphraseFixture),
|
||||||
|
Password: "invalid",
|
||||||
|
},
|
||||||
|
wantErr: "invalid 'ssh' auth option 'identity': x509: decryption password incorrect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH transport requires known_hosts",
|
||||||
|
opts: AuthOptions{
|
||||||
|
Transport: SSH,
|
||||||
|
Identity: []byte(privateKeyFixture),
|
||||||
|
},
|
||||||
|
wantErr: "invalid 'ssh' auth option: 'known_hosts' is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Requires transport",
|
||||||
|
opts: AuthOptions{},
|
||||||
|
wantErr: "no transport type set",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
got := tt.opts.Validate()
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
g.Expect(got.Error()).To(ContainSubstring(tt.wantErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Expect(got).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthOptionsFromSecret(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
URL string
|
||||||
|
secret *v1.Secret
|
||||||
|
wantFunc func(g *WithT, opts *AuthOptions, secret *v1.Secret)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Sets values from Secret",
|
||||||
|
URL: "https://git@example.com",
|
||||||
|
secret: &v1.Secret{
|
||||||
|
Data: map[string][]byte{
|
||||||
|
"username": []byte("example"), // This takes precedence over the one from the URL
|
||||||
|
"password": []byte("secret"),
|
||||||
|
"identity": []byte(privateKeyFixture),
|
||||||
|
"known_hosts": []byte(knownHostsFixture),
|
||||||
|
"caFile": []byte("mock"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantFunc: func(g *WithT, opts *AuthOptions, secret *v1.Secret) {
|
||||||
|
g.Expect(opts.Username).To(Equal("example"))
|
||||||
|
g.Expect(opts.Password).To(Equal("secret"))
|
||||||
|
g.Expect(opts.Identity).To(BeEquivalentTo(privateKeyFixture))
|
||||||
|
g.Expect(opts.KnownHosts).To(BeEquivalentTo(knownHostsFixture))
|
||||||
|
g.Expect(opts.CAFile).To(BeEquivalentTo("mock"))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sets default user",
|
||||||
|
URL: "http://example.com",
|
||||||
|
secret: &v1.Secret{},
|
||||||
|
wantFunc: func(g *WithT, opts *AuthOptions, secret *v1.Secret) {
|
||||||
|
g.Expect(opts.Username).To(Equal(DefaultPublicKeyAuthUser))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sets transport from URL",
|
||||||
|
URL: "http://git@example.com",
|
||||||
|
secret: &v1.Secret{},
|
||||||
|
wantFunc: func(g *WithT, opts *AuthOptions, secret *v1.Secret) {
|
||||||
|
g.Expect(opts.Transport).To(Equal(HTTP))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sets user from URL",
|
||||||
|
URL: "http://example@example.com",
|
||||||
|
secret: &v1.Secret{
|
||||||
|
Data: map[string][]byte{
|
||||||
|
"password": []byte("secret"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantFunc: func(g *WithT, opts *AuthOptions, secret *v1.Secret) {
|
||||||
|
g.Expect(opts.Username).To(Equal("example"))
|
||||||
|
g.Expect(opts.Password).To(Equal("secret"))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Validates options",
|
||||||
|
URL: "ssh://example.com",
|
||||||
|
secret: &v1.Secret{
|
||||||
|
Data: map[string][]byte{
|
||||||
|
"identity": []byte(privateKeyFixture),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: "invalid 'ssh' auth option: 'known_hosts' is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Errors without secret",
|
||||||
|
secret: nil,
|
||||||
|
wantErr: "no secret provided to construct auth strategy from",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Errors on malformed URL",
|
||||||
|
URL: ":example",
|
||||||
|
secret: &v1.Secret{},
|
||||||
|
wantErr: "failed to parse URL to determine auth strategy",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
g := NewWithT(t)
|
||||||
|
|
||||||
|
got, err := AuthOptionsFromSecret(tt.URL, tt.secret)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
g.Expect(err).To(HaveOccurred())
|
||||||
|
g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
|
||||||
|
g.Expect(got).To(BeNil())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Expect(err).To(BeNil())
|
||||||
|
if tt.wantFunc != nil {
|
||||||
|
tt.wantFunc(g, got, tt.secret)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,17 +32,6 @@ func CheckoutStrategyForRef(ref *sourcev1.GitRepositoryRef, opt git.CheckoutOpti
|
||||||
case sourcev1.LibGit2Implementation:
|
case sourcev1.LibGit2Implementation:
|
||||||
return libgit2.CheckoutStrategyForRef(ref, opt), nil
|
return libgit2.CheckoutStrategyForRef(ref, opt), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("invalid Git implementation %s", opt.GitImplementation)
|
return nil, fmt.Errorf("unsupported Git implementation %s", opt.GitImplementation)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuthSecretStrategyForURL(url string, opt git.CheckoutOptions) (git.AuthSecretStrategy, error) {
|
|
||||||
switch opt.GitImplementation {
|
|
||||||
case sourcev1.GoGitImplementation:
|
|
||||||
return gogit.AuthSecretStrategyForURL(url)
|
|
||||||
case sourcev1.LibGit2Implementation:
|
|
||||||
return libgit2.AuthSecretStrategyForURL(url)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("invalid Git implementation %s", opt.GitImplementation)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue