diff --git a/api/v1beta1/gitrepository_types.go b/api/v1beta1/gitrepository_types.go index 6c178d02c..b2471df00 100644 --- a/api/v1beta1/gitrepository_types.go +++ b/api/v1beta1/gitrepository_types.go @@ -120,7 +120,6 @@ type GitRepositoryInclude struct { // GitRepositoryRef defines the Git ref used for pull and checkout operations. type GitRepositoryRef struct { // The Git branch to checkout, defaults to master. - // +kubebuilder:default:=master // +optional Branch string `json:"branch,omitempty"` diff --git a/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml b/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml index dffd8599a..f6f523edd 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml @@ -91,7 +91,6 @@ spec: description: The Git reference to checkout and monitor for changes, defaults to master branch. properties: branch: - default: master description: The Git branch to checkout, defaults to master. type: string commit: diff --git a/controllers/gitrepository_controller.go b/controllers/gitrepository_controller.go index ba32f1664..4da7d9ae1 100644 --- a/controllers/gitrepository_controller.go +++ b/controllers/gitrepository_controller.go @@ -229,45 +229,35 @@ func (r *GitRepositoryReconciler) reconcile(ctx context.Context, repository sour } defer os.RemoveAll(tmpGit) - // determine auth method - auth := &git.Auth{} + // Configure auth options using secret + var authOpts *git.AuthOptions 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{ Namespace: repository.GetNamespace(), Name: repository.Spec.SecretRef.Name, } - var secret corev1.Secret - err = r.Client.Get(ctx, name, &secret) + secret := &corev1.Secret{} + err = r.Client.Get(ctx, name, secret) if err != nil { err = fmt.Errorf("auth secret error: %w", 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 { - err = fmt.Errorf("auth error: %w", err) return sourcev1.GitRepositoryNotReady(repository, sourcev1.AuthenticationFailedReason, err.Error()), err } } - - checkoutStrategy, err := strategy.CheckoutStrategyForRef( - repository.Spec.Reference, - git.CheckoutOptions{ - GitImplementation: repository.Spec.GitImplementation, - RecurseSubmodules: repository.Spec.RecurseSubmodules, - }, - ) + checkoutOpts := git.CheckoutOptions{RecurseSubmodules: repository.Spec.RecurseSubmodules} + if ref := repository.Spec.Reference; ref != nil { + checkoutOpts.Branch = ref.Branch + checkoutOpts.Commit = ref.Commit + checkoutOpts.Tag = ref.Tag + checkoutOpts.SemVer = ref.SemVer + } + checkoutStrategy, err := strategy.CheckoutStrategyForImplementation(ctx, + git.Implementation(repository.Spec.GitImplementation), checkoutOpts) if err != nil { return sourcev1.GitRepositoryNotReady(repository, sourcev1.GitOperationFailedReason, err.Error()), err } @@ -275,12 +265,11 @@ func (r *GitRepositoryReconciler) reconcile(ctx context.Context, repository sour gitCtx, cancel := context.WithTimeout(ctx, repository.Spec.Timeout.Duration) defer cancel() - commit, revision, err := checkoutStrategy.Checkout(gitCtx, tmpGit, repository.Spec.URL, auth) + commit, err := checkoutStrategy.Checkout(gitCtx, tmpGit, repository.Spec.URL, authOpts) if err != nil { return sourcev1.GitRepositoryNotReady(repository, sourcev1.GitOperationFailedReason, err.Error()), err } - - artifact := r.Storage.NewArtifactFor(repository.Kind, repository.GetObjectMeta(), revision, fmt.Sprintf("%s.tar.gz", commit.Hash())) + artifact := r.Storage.NewArtifactFor(repository.Kind, repository.GetObjectMeta(), commit.String(), fmt.Sprintf("%s.tar.gz", commit.Hash.String())) // copy all included repository into the artifact includedArtifacts := []*sourcev1.Artifact{} @@ -309,14 +298,17 @@ func (r *GitRepositoryReconciler) reconcile(ctx context.Context, repository sour Namespace: repository.Namespace, Name: repository.Spec.Verification.SecretRef.Name, } - var secret corev1.Secret - if err := r.Client.Get(ctx, publicKeySecret, &secret); err != nil { + var secret *corev1.Secret + if err := r.Client.Get(ctx, publicKeySecret, secret); err != nil { err = fmt.Errorf("PGP public keys secret error: %w", err) return sourcev1.GitRepositoryNotReady(repository, sourcev1.VerificationFailedReason, err.Error()), err } - err := commit.Verify(secret) - if err != nil { + var keyRings []string + for _, v := range secret.Data { + keyRings = append(keyRings, string(v)) + } + if _, err = commit.Verify(keyRings...); err != nil { return sourcev1.GitRepositoryNotReady(repository, sourcev1.VerificationFailedReason, err.Error()), err } } diff --git a/controllers/gitrepository_controller_test.go b/controllers/gitrepository_controller_test.go index c647727c6..f2fd6295d 100644 --- a/controllers/gitrepository_controller_test.go +++ b/controllers/gitrepository_controller_test.go @@ -23,11 +23,9 @@ import ( "net/http" "net/url" "os" - "os/exec" "path" "path/filepath" - "strings" "time" @@ -251,7 +249,7 @@ var _ = Describe("GitRepositoryReconciler", func() { reference: &sourcev1.GitRepositoryRef{SemVer: "1.2.3.4"}, waitForReason: sourcev1.GitOperationFailedReason, expectStatus: metav1.ConditionFalse, - expectMessage: "semver parse range error: improper constraint: 1.2.3.4", + expectMessage: "semver parse error: improper constraint: 1.2.3.4", }), Entry("semver no match", refTestCase{ reference: &sourcev1.GitRepositoryRef{SemVer: "1.0.0"}, @@ -265,7 +263,7 @@ var _ = Describe("GitRepositoryReconciler", func() { }, waitForReason: sourcev1.GitOperationSucceedReason, expectStatus: metav1.ConditionTrue, - expectRevision: "master", + expectRevision: "HEAD", }), Entry("commit in branch", refTestCase{ reference: &sourcev1.GitRepositoryRef{ @@ -284,7 +282,7 @@ var _ = Describe("GitRepositoryReconciler", func() { }, waitForReason: sourcev1.GitOperationFailedReason, expectStatus: metav1.ConditionFalse, - expectMessage: "git commit 'invalid' not found: object not found", + expectMessage: "failed to resolve commit object for 'invalid': object not found", }), ) @@ -385,7 +383,7 @@ var _ = Describe("GitRepositoryReconciler", func() { reference: &sourcev1.GitRepositoryRef{Branch: "main"}, waitForReason: sourcev1.GitOperationFailedReason, expectStatus: metav1.ConditionFalse, - expectMessage: "error: user rejected certificate", + expectMessage: "unable to clone: user rejected certificate", gitImplementation: sourcev1.LibGit2Implementation, }), Entry("self signed libgit2 with CA", refTestCase{ diff --git a/controllers/helmchart_controller.go b/controllers/helmchart_controller.go index ee6b93e90..5d4f952cd 100644 --- a/controllers/helmchart_controller.go +++ b/controllers/helmchart_controller.go @@ -529,7 +529,7 @@ func (r *HelmChartReconciler) reconcileFromTarballArtifact(ctx context.Context, v, err := semver.NewVersion(helmChart.Metadata.Version) if err != nil { - err = fmt.Errorf("semver error: %w", err) + err = fmt.Errorf("semver parse error: %w", err) return sourcev1.HelmChartNotReady(chart, sourcev1.StorageOperationFailedReason, err.Error()), err } @@ -539,7 +539,7 @@ func (r *HelmChartReconciler) reconcileFromTarballArtifact(ctx context.Context, splitRev := strings.Split(artifact.Revision, "/") v, err := v.SetMetadata(splitRev[len(splitRev)-1]) if err != nil { - err = fmt.Errorf("semver error: %w", err) + err = fmt.Errorf("semver parse error: %w", err) return sourcev1.HelmChartNotReady(chart, sourcev1.StorageOperationFailedReason, err.Error()), err } diff --git a/docs/spec/v1beta1/gitrepositories.md b/docs/spec/v1beta1/gitrepositories.md index c302c07a0..eae06ffbe 100644 --- a/docs/spec/v1beta1/gitrepositories.md +++ b/docs/spec/v1beta1/gitrepositories.md @@ -273,6 +273,21 @@ spec: commit: 363a6a8fe6a7f13e05d34c163b0ef02a777da20a ``` +Checkout a specific commit: + +```yaml +apiVersion: source.toolkit.fluxcd.io/v1beta1 +kind: GitRepository +metadata: + name: podinfo + namespace: default +spec: + interval: 1m + url: https://github.com/stefanprodan/podinfo + ref: + commit: 363a6a8fe6a7f13e05d34c163b0ef02a777da20a +``` + Pull a specific tag: ```yaml diff --git a/go.mod b/go.mod index 2dbe98060..9eaaed4ed 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,10 @@ require ( cloud.google.com/go v0.93.3 // indirect cloud.google.com/go/storage v1.16.0 github.com/Masterminds/semver/v3 v3.1.1 + github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 github.com/cyphar/filepath-securejoin v0.2.2 github.com/fluxcd/pkg/apis/meta v0.10.0 - github.com/fluxcd/pkg/gittestserver v0.3.0 + github.com/fluxcd/pkg/gittestserver v0.4.1 github.com/fluxcd/pkg/gitutil v0.1.0 github.com/fluxcd/pkg/helmtestserver v0.2.0 github.com/fluxcd/pkg/lockedfile v0.1.0 diff --git a/go.sum b/go.sum index 34c48c50a..9ebb6018b 100644 --- a/go.sum +++ b/go.sum @@ -266,8 +266,8 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fluxcd/pkg/apis/meta v0.10.0 h1:N7wVGHC1cyPdT87hrDC7UwCwRwnZdQM46PBSLjG2rlE= github.com/fluxcd/pkg/apis/meta v0.10.0/go.mod h1:CW9X9ijMTpNe7BwnokiUOrLl/h13miwVr/3abEQLbKE= -github.com/fluxcd/pkg/gittestserver v0.3.0 h1:6aa30mybecBwBWaJ2IEk7pQzefWnjWjxkTSrHMHawvg= -github.com/fluxcd/pkg/gittestserver v0.3.0/go.mod h1:8j36Z6B0BuKNZZ6exAWoyDEpyQoFcjz1IX3WBT7PZNg= +github.com/fluxcd/pkg/gittestserver v0.4.1 h1:knghRrVEEPnpO0VJYjoz0H2YMc4fnKAVt5hDGsB1IHc= +github.com/fluxcd/pkg/gittestserver v0.4.1/go.mod h1:hUPx21fe/6oox336Wih/XF1fnmzLmptNMOvATbTZXNY= github.com/fluxcd/pkg/gitutil v0.1.0 h1:VO3kJY/CKOCO4ysDNqfdpTg04icAKBOSb3lbR5uE/IE= github.com/fluxcd/pkg/gitutil v0.1.0/go.mod h1:Ybz50Ck5gkcnvF0TagaMwtlRy3X3wXuiri1HVsK5id4= github.com/fluxcd/pkg/helmtestserver v0.2.0 h1:cE7YHDmrWI0hr9QpaaeQ0vQ16Z0IiqZKiINDpqdY610= @@ -984,7 +984,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= diff --git a/pkg/git/git.go b/pkg/git/git.go index 6ec7257ae..780243157 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -17,43 +17,82 @@ limitations under the License. package git import ( + "bytes" "context" + "fmt" + "strings" + "time" - "github.com/go-git/go-git/v5/plumbing/transport" - git2go "github.com/libgit2/git2go/v31" - corev1 "k8s.io/api/core/v1" + "github.com/ProtonMail/go-crypto/openpgp" ) -const ( - DefaultOrigin = "origin" - DefaultBranch = "master" - DefaultPublicKeyAuthUser = "git" - CAFile = "caFile" -) +type Implementation string + +type Hash []byte -type Commit interface { - Verify(secret corev1.Secret) error - Hash() string +// String returns the SHA1 Hash as a string. +func (h Hash) String() string { + return string(h) } -type CheckoutStrategy interface { - Checkout(ctx context.Context, path, url string, auth *Auth) (Commit, string, error) +type Signature struct { + Name string + Email string + When time.Time } -type CheckoutOptions struct { - GitImplementation string - RecurseSubmodules bool +type Commit struct { + // Hash is the SHA1 hash of the commit. + Hash Hash + // Reference is the original reference of the commit, for example: + // 'refs/tags/foo'. + Reference string + // Author is the original author of the commit. + Author Signature + // Committer is the one performing the commit, might be different from + // Author. + Committer Signature + // Signature is the PGP signature of the commit. + Signature string + // Encoded is the encoded commit, without any signature. + Encoded []byte + // Message is the commit message, contains arbitrary text. + Message string } -// 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 +// String returns a string representation of the Commit, composed +// out the last part of the Reference element, and/or Hash. +// For example: 'tag-1/a0c14dc8580a23f79bc654faa79c4f62b46c2c22', +// for a "tag-1" tag. +func (c *Commit) String() string { + if short := strings.SplitAfterN(c.Reference, "/", 3); len(short) == 3 { + return fmt.Sprintf("%s/%s", short[2], c.Hash) + } + return fmt.Sprintf("HEAD/%s", c.Hash) } -type AuthSecretStrategy interface { - Method(secret corev1.Secret) (*Auth, error) +// Verify the Signature of the commit with the given key rings. +// It returns the fingerprint of the key the signature was verified +// with, or an error. +func (c *Commit) Verify(keyRing ...string) (string, error) { + if c.Signature == "" { + return "", fmt.Errorf("commit does not have a PGP signature") + } + + for _, r := range keyRing { + reader := strings.NewReader(r) + keyring, err := openpgp.ReadArmoredKeyRing(reader) + if err != nil { + return "", fmt.Errorf("failed to read armored key ring: %w", err) + } + signer, err := openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewBuffer(c.Encoded), bytes.NewBufferString(c.Signature), nil) + if err == nil { + return fmt.Sprintf("%X", signer.PrimaryKey.Fingerprint[12:20]), nil + } + } + return "", fmt.Errorf("failed to verify commit with any of the given key rings") +} + +type CheckoutStrategy interface { + Checkout(ctx context.Context, path, url string, config *AuthOptions) (*Commit, error) } diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go new file mode 100644 index 000000000..ccaed91e4 --- /dev/null +++ b/pkg/git/git_test.go @@ -0,0 +1,220 @@ +/* +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" +) + +const ( + encodedCommitFixture = `tree f0c522d8cc4c90b73e2bc719305a896e7e3c108a +parent eb167bc68d0a11530923b1f24b4978535d10b879 +author Stefan Prodan 1633681364 +0300 +committer Stefan Prodan 1633681364 +0300 + +Update containerd and runc to fix CVEs + +Signed-off-by: Stefan Prodan +` + + malformedEncodedCommitFixture = `parent eb167bc68d0a11530923b1f24b4978535d10b879 +author Stefan Prodan 1633681364 +0300 +committer Stefan Prodan 1633681364 +0300 + +Update containerd and runc to fix CVEs + +Signed-off-by: Stefan Prodan +` + + signatureCommitFixture = `-----BEGIN PGP SIGNATURE----- + +iHUEABEIAB0WIQQHgExUr4FrLdKzpNYyma6w5AhbrwUCYV//1AAKCRAyma6w5Ahb +r7nJAQCQU4zEJu04/Q0ac/UaL6htjhq/wTDNMeUM+aWG/LcBogEAqFUea1oR2BJQ +JCJmEtERFh39zNWSazQmxPAFhEE0kbc= +=+Wlj +-----END PGP SIGNATURE-----` + + armoredKeyRingFixture = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQSuBF9+HgMRDADKT8UBcSzpTi4JXt/ohhVW3x81AGFPrQvs6MYrcnNJfIkPTJD8 +mY5T7j1fkaN5wcf1wnxM9qTcW8BodkWNGEoEYOtVuigLSxPFqIncxK0PHvdU8ths +TEInBrgZv9t6xIVa4QngOEUd2D/aYni7M+75z7ntgj6eU1xLZ60upRFn05862OvJ +rZFUvzjsZXMAO3enCu2VhG/2axCY/5uI8PgWjyiKV2TH4LBJgzlb0v6SyI+fYf5K +Bg2WzDuLKvQBi9tFSwnUbQoFFlOeiGW8G/bdkoJDWeS1oYgSD3nkmvXvrVESCrbT +C05OtQOiDXjSpkLim81vNVPtI2XEug+9fEA+jeJakyGwwB+K8xqV3QILKCoWHKGx +yWcMHSR6cP9tdXCk2JHZBm1PLSJ8hIgMH/YwBJLYg90u8lLAs9WtpVBKkLplzzgm +B4Z4VxCC+xI1kt+3ZgYvYC+oUXJXrjyAzy+J1f+aWl2+S/79glWgl/xz2VibWMz6 +nZUE+wLMxOQqyOsBALsoE6z81y/7gfn4R/BziBASi1jq/r/wdboFYowmqd39DACX ++i+V0OplP2TN/F5JajzRgkrlq5cwZHinnw+IFwj9RTfOkdGb3YwhBt/h2PP38969 +ZG+y8muNtaIqih1pXj1fz9HRtsiCABN0j+JYpvV2D2xuLL7P1O0dt5BpJ3KqNCRw +mGgO2GLxbwvlulsLidCPxdK/M8g9Eeb/xwA5LVwvjVchHkzHuUT7durn7AT0RWiK +BT8iDfeBB9RKienAbWyybEqRaR6/Tv+mghFIalsDiBPbfm4rsNzsq3ohfByqECiy +yUvs2O3NDwkoaBDkA3GFyKv8/SVpcuL5OkVxAHNCIMhNzSgotQ3KLcQc0IREfFCa +3CsBAC7CsE2bJZ9IA9sbBa3jimVhWUQVudRWiLFeYHUF/hjhqS8IHyFwprjEOLaV +EG0kBO6ELypD/bOsmN9XZLPYyI3y9DM6Vo0KMomE+yK/By/ZMxVfex8/TZreUdhP +VdCLL95Rc4w9io8qFb2qGtYBij2wm0RWLcM0IhXWAtjI3B17IN+6hmv+JpiZccsM +AMNR5/RVdXIl0hzr8LROD0Xe4sTyZ+fm3mvpczoDPQNRrWpmI/9OT58itnVmZ5jM +7djV5y/NjBk63mlqYYfkfWto97wkhg0MnTnOhzdtzSiZQRzj+vf+ilLfIlLnuRr1 +JRV9Skv6xQltcFArx4JyfZCo7JB1ZXcbdFAvIXXS11RTErO0XVrXNm2RenpW/yZA +9f+ESQ/uUB6XNuyqVUnJDAFJFLdzx8sO3DXo7dhIlgpFqgQobUl+APpbU5LT95sm +89UrV0Lt9vh7k6zQtKOjEUhm+dErmuBnJo8MvchAuXLagHjvb58vYBCUxVxzt1KG +2IePwJ/oXIfawNEGad9Lmdo1FYG1u53AKWZmpYOTouu92O50FG2+7dBh0V2vO253 +aIGFRT1r14B1pkCIun7z7B/JELqOkmwmlRrUnxlADZEcQT3z/S8/4+2P7P6kXO7X +/TAX5xBhSqUbKe3DhJSOvf05/RVL5ULc2U2JFGLAtmBOFmnD/u0qoo5UvWliI+v/ +47QnU3RlZmFuIFByb2RhbiA8c3RlZmFuLnByb2RhbkBnbWFpbC5jb20+iJAEExEI +ADgWIQQHgExUr4FrLdKzpNYyma6w5AhbrwUCX34eAwIbAwULCQgHAgYVCgkICwIE +FgIDAQIeAQIXgAAKCRAyma6w5Ahbrzu/AP9l2YpRaWZr6wSQuEn0gMN8DRzsWJPx +pn0akdY7SRP3ngD9GoKgu41FAItnHAJ2KiHv/fHFyHMndNP3kPGPNW4BF+65Aw0E +X34eAxAMAMdYFCHmVA8TZxSTMBDpKYave8RiDCMMMjk26Gl0EPN9f2Y+s5++DhiQ +hojNH9VmJkFwZX1xppxe1y1aLa/U6fBAqMP/IdNH8270iv+A9YIxdsWLmpm99BDO +3suRfsHcOe9T0x/CwRfDNdGM/enGMhYGTgF4VD58DRDE6WntaBhl4JJa300NG6X0 +GM4Gh59DKWDnez/Shulj8demlWmakP5imCVoY+omOEc2k3nH02U+foqaGG5WxZZ+ +GwEPswm2sBxvn8nwjy9gbQwEtzNI7lWYiz36wCj2VS56Udqt+0eNg8WzocUT0XyI +moe1qm8YJQ6fxIzaC431DYi/mCDzgx4EV9ww33SXX3Yp2NL6PsdWJWw2QnoqSMpM +z5otw2KlMgUHkkXEKs0apmK4Hu2b6KD7/ydoQRFUqR38Gb0IZL1tOL6PnbCRUcig +Aypy016W/WMCjBfQ8qxIGTaj5agX2t28hbiURbxZkCkz+Z3OWkO0Rq3Y2hNAYM5s +eTn94JIGGwADBgv/dbSZ9LrBvdMwg8pAtdlLtQdjPiT1i9w5NZuQd7OuKhOxYTEB +NRDTgy4/DgeNThCeOkMB/UQQPtJ3Et45S2YRtnnuvfxgnlz7xlUn765/grtnRk4t +ONjMmb6tZos1FjIJecB/6h4RsvUd2egvtlpD/Z3YKr6MpNjWg4ji7m27e9pcJfP6 +YpTDrq9GamiHy9FS2F2pZlQxriPpVhjCLVn9tFGBIsXNxxn7SP4so6rJBmyHEAlq +iym9wl933e0FIgAw5C1vvprYu2amk+jmVBsJjjCmInW5q/kWAFnFaHBvk+v+/7tX +hywWUI7BqseikgUlkgJ6eU7E9z1DEyuS08x/cViDoNh2ntVUhpnluDu48pdqBvvY +a4uL/D+KI84THUAJ/vZy+q6G3BEb4hI9pFjgrdJpUKubxyZolmkCFZHjV34uOcTc +LQr28P8xW8vQbg5DpIsivxYLqDGXt3OyiItxvLMtw/ypt6PkoeP9A4KDST4StITE +1hrOrPtJ/VRmS2o0iHgEGBEIACAWIQQHgExUr4FrLdKzpNYyma6w5AhbrwUCX34e +AwIbDAAKCRAyma6w5Ahbr6QWAP9/pl2R6r1nuCnXzewSbnH1OLsXf32hFQAjaQ5o +Oomb3gD/TRf/nAdVED+k81GdLzciYdUGtI71/qI47G0nMBluLRE= +=/4e+ +-----END PGP PUBLIC KEY BLOCK----- +` + + keyRingFingerprintFixture = "3299AEB0E4085BAF" + + malformedKeyRingFixture = ` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQSuBF9+HgMRDADKT8UBcSzpTi4JXt/ohhVW3x81AGFPrQvs6MYrcnNJfIkPTJD8 +mY5T7j1fkaN5wcf1wnxM9qTcW8BodkWNGEoEYOtVuigLSxPFqIncxK0PHvdU8ths +TEInBrgZv9t6xIVa4QngOEUd2D/aYni7M+75z7ntgj6eU1xLZ60upRFn05862OvJ +rZFUvzjsZXMAO3enCu2VhG/2axCY/5uI8PgWjyiKV2TH4LBJgzlb0v6SyI+fYf5K +Bg2WzDuLKvQBi9tFSwnUbQoFFlOeiGW8G/bdkoJDWeS1oYgSD3nkmvXvrVESCrbT +-----END PGP PUBLIC KEY BLOCK----- +` +) + +func TestCommit_String(t *testing.T) { + tests := []struct { + name string + commit *Commit + want string + }{ + { + name: "Reference and commit", + commit: &Commit{ + Hash: []byte("commit"), + Reference: "refs/heads/main", + }, + want: "main/commit", + }, + { + name: "Reference with slash and commit", + commit: &Commit{ + Hash: []byte("commit"), + Reference: "refs/heads/feature/branch", + }, + want: "feature/branch/commit", + }, + { + name: "No reference", + commit: &Commit{ + Hash: []byte("commit"), + }, + want: "HEAD/commit", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + g.Expect(tt.commit.String()).To(Equal(tt.want)) + }) + } +} + +func TestCommit_Verify(t *testing.T) { + tests := []struct { + name string + commit *Commit + keyRings []string + want string + wantErr string + }{ + { + name: "Valid commit signature", + commit: &Commit{ + Encoded: []byte(encodedCommitFixture), + Signature: signatureCommitFixture, + }, + keyRings: []string{armoredKeyRingFixture}, + want: keyRingFingerprintFixture, + }, + { + name: "Malformed encoded commit", + commit: &Commit{ + Encoded: []byte(malformedEncodedCommitFixture), + Signature: signatureCommitFixture, + }, + keyRings: []string{armoredKeyRingFixture}, + wantErr: "failed to verify commit with any of the given key rings", + }, + { + name: "Malformed key ring", + commit: &Commit{ + Encoded: []byte(encodedCommitFixture), + Signature: signatureCommitFixture, + }, + keyRings: []string{malformedKeyRingFixture}, + wantErr: "failed to read armored key ring: unexpected EOF", + }, + { + name: "Missing signature", + commit: &Commit{ + Encoded: []byte(encodedCommitFixture), + }, + keyRings: []string{armoredKeyRingFixture}, + wantErr: "commit does not have a PGP signature", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := tt.commit.Verify(tt.keyRings...) + if tt.wantErr != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr)) + g.Expect(got).To(BeEmpty()) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(Equal(tt.want)) + }) + } +} diff --git a/pkg/git/gogit/checkout.go b/pkg/git/gogit/checkout.go index e767b37a2..8667ce19f 100644 --- a/pkg/git/gogit/checkout.go +++ b/pkg/git/gogit/checkout.go @@ -18,177 +18,200 @@ package gogit import ( "context" + "errors" "fmt" + "io" "sort" "time" "github.com/Masterminds/semver/v3" extgogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" "github.com/fluxcd/pkg/gitutil" "github.com/fluxcd/pkg/version" - sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" "github.com/fluxcd/source-controller/pkg/git" ) -func CheckoutStrategyForRef(ref *sourcev1.GitRepositoryRef, opt git.CheckoutOptions) git.CheckoutStrategy { +// CheckoutStrategyForOptions returns the git.CheckoutStrategy for the given +// git.CheckoutOptions. +func CheckoutStrategyForOptions(_ context.Context, opts git.CheckoutOptions) git.CheckoutStrategy { switch { - case ref == nil: - return &CheckoutBranch{branch: git.DefaultBranch} - case ref.SemVer != "": - return &CheckoutSemVer{semVer: ref.SemVer, recurseSubmodules: opt.RecurseSubmodules} - case ref.Tag != "": - return &CheckoutTag{tag: ref.Tag, recurseSubmodules: opt.RecurseSubmodules} - case ref.Commit != "": - strategy := &CheckoutCommit{branch: ref.Branch, commit: ref.Commit, recurseSubmodules: opt.RecurseSubmodules} - if strategy.branch == "" { - strategy.branch = git.DefaultBranch - } - return strategy - case ref.Branch != "": - return &CheckoutBranch{branch: ref.Branch, recurseSubmodules: opt.RecurseSubmodules} + case opts.Commit != "": + return &CheckoutCommit{Branch: opts.Branch, Commit: opts.Commit, RecurseSubmodules: opts.RecurseSubmodules} + case opts.SemVer != "": + return &CheckoutSemVer{SemVer: opts.SemVer, RecurseSubmodules: opts.RecurseSubmodules} + case opts.Tag != "": + return &CheckoutTag{Tag: opts.Tag, RecurseSubmodules: opts.RecurseSubmodules} default: - return &CheckoutBranch{branch: git.DefaultBranch} + branch := opts.Branch + if branch == "" { + branch = git.DefaultBranch + } + return &CheckoutBranch{Branch: branch, RecurseSubmodules: opts.RecurseSubmodules} } } type CheckoutBranch struct { - branch string - recurseSubmodules bool + Branch string + 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, error) { + authMethod, err := transportAuth(opts) + if err != nil { + return nil, fmt.Errorf("failed to construct auth method with options: %w", err) + } + ref := plumbing.NewBranchReferenceName(c.Branch) repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{ URL: url, - Auth: auth.AuthMethod, + Auth: authMethod, RemoteName: git.DefaultOrigin, - ReferenceName: plumbing.NewBranchReferenceName(c.branch), + ReferenceName: plumbing.NewBranchReferenceName(c.Branch), SingleBranch: true, NoCheckout: false, Depth: 1, - RecurseSubmodules: recurseSubmodules(c.recurseSubmodules), + RecurseSubmodules: recurseSubmodules(c.RecurseSubmodules), Progress: nil, Tags: extgogit.NoTags, - CABundle: auth.CABundle, + CABundle: caBundle(opts), }) 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': %w", url, gitutil.GoGitError(err)) } head, err := repo.Head() if err != nil { - return nil, "", fmt.Errorf("git resolve HEAD error: %w", err) + return nil, fmt.Errorf("failed to resolve HEAD of branch '%s': %w", c.Branch, err) } - commit, err := repo.CommitObject(head.Hash()) + cc, err := repo.CommitObject(head.Hash()) if err != nil { - return nil, "", fmt.Errorf("git commit '%s' not found: %w", head.Hash(), err) + return nil, fmt.Errorf("failed to resolve commit object for HEAD '%s': %w", head.Hash(), err) } - return &Commit{commit}, fmt.Sprintf("%s/%s", c.branch, head.Hash().String()), nil + return buildCommitWithRef(cc, ref) } type CheckoutTag struct { - tag string - recurseSubmodules bool + Tag string + 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, error) { + authMethod, err := transportAuth(opts) + if err != nil { + return nil, fmt.Errorf("failed to construct auth method with options: %w", err) + } + ref := plumbing.NewTagReferenceName(c.Tag) repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{ URL: url, - Auth: auth.AuthMethod, + Auth: authMethod, RemoteName: git.DefaultOrigin, - ReferenceName: plumbing.NewTagReferenceName(c.tag), + ReferenceName: plumbing.NewTagReferenceName(c.Tag), SingleBranch: true, NoCheckout: false, Depth: 1, - RecurseSubmodules: recurseSubmodules(c.recurseSubmodules), + RecurseSubmodules: recurseSubmodules(c.RecurseSubmodules), Progress: nil, Tags: extgogit.NoTags, - CABundle: auth.CABundle, + CABundle: caBundle(opts), }) if err != nil { - return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, err) + return nil, fmt.Errorf("unable to clone '%s': %w", url, gitutil.GoGitError(err)) } head, err := repo.Head() if err != nil { - return nil, "", fmt.Errorf("git resolve HEAD error: %w", err) + return nil, fmt.Errorf("failed to resolve HEAD of tag '%s': %w", c.Tag, err) } - commit, err := repo.CommitObject(head.Hash()) + cc, err := repo.CommitObject(head.Hash()) if err != nil { - return nil, "", fmt.Errorf("git commit '%s' not found: %w", head.Hash(), err) + return nil, fmt.Errorf("failed to resolve commit object for HEAD '%s': %w", head.Hash(), err) } - return &Commit{commit}, fmt.Sprintf("%s/%s", c.tag, head.Hash().String()), nil + return buildCommitWithRef(cc, ref) } type CheckoutCommit struct { - branch string - commit string - recurseSubmodules bool + Branch string + Commit string + RecurseSubmodules bool } -func (c *CheckoutCommit) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) { - repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{ +func (c *CheckoutCommit) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (*git.Commit, error) { + authMethod, err := transportAuth(opts) + if err != nil { + return nil, fmt.Errorf("failed to construct auth method with options: %w", err) + } + cloneOpts := &extgogit.CloneOptions{ URL: url, - Auth: auth.AuthMethod, + Auth: authMethod, RemoteName: git.DefaultOrigin, - ReferenceName: plumbing.NewBranchReferenceName(c.branch), - SingleBranch: true, - NoCheckout: false, - RecurseSubmodules: recurseSubmodules(c.recurseSubmodules), + SingleBranch: false, + NoCheckout: true, + RecurseSubmodules: recurseSubmodules(c.RecurseSubmodules), Progress: nil, Tags: extgogit.NoTags, - CABundle: auth.CABundle, - }) + CABundle: caBundle(opts), + } + if c.Branch != "" { + cloneOpts.SingleBranch = true + cloneOpts.ReferenceName = plumbing.NewBranchReferenceName(c.Branch) + } + repo, err := extgogit.PlainCloneContext(ctx, path, false, cloneOpts) if err != nil { - return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, err) + return nil, fmt.Errorf("unable to clone '%s': %w", url, gitutil.GoGitError(err)) } w, err := repo.Worktree() if err != nil { - return nil, "", fmt.Errorf("git worktree error: %w", err) + return nil, fmt.Errorf("failed to open Git worktree: %w", err) } - commit, err := repo.CommitObject(plumbing.NewHash(c.commit)) + cc, err := repo.CommitObject(plumbing.NewHash(c.Commit)) if err != nil { - return nil, "", fmt.Errorf("git commit '%s' not found: %w", c.commit, err) + return nil, fmt.Errorf("failed to resolve commit object for '%s': %w", c.Commit, err) } err = w.Checkout(&extgogit.CheckoutOptions{ - Hash: commit.Hash, + Hash: cc.Hash, Force: true, }) if err != nil { - return nil, "", fmt.Errorf("git checkout error: %w", err) + return nil, fmt.Errorf("failed to checkout commit '%s': %w", c.Commit, err) } - return &Commit{commit}, fmt.Sprintf("%s/%s", c.branch, commit.Hash.String()), nil + return buildCommitWithRef(cc, cloneOpts.ReferenceName) } type CheckoutSemVer struct { - semVer string - recurseSubmodules bool + SemVer string + RecurseSubmodules bool } -func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) { - verConstraint, err := semver.NewConstraint(c.semVer) +func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (*git.Commit, error) { + verConstraint, err := semver.NewConstraint(c.SemVer) if err != nil { - return nil, "", fmt.Errorf("semver parse range error: %w", err) + return nil, fmt.Errorf("semver parse error: %w", err) + } + + authMethod, err := transportAuth(opts) + if err != nil { + return nil, fmt.Errorf("failed to construct auth method with options: %w", err) } repo, err := extgogit.PlainCloneContext(ctx, path, false, &extgogit.CloneOptions{ URL: url, - Auth: auth.AuthMethod, + Auth: authMethod, RemoteName: git.DefaultOrigin, NoCheckout: false, Depth: 1, - RecurseSubmodules: recurseSubmodules(c.recurseSubmodules), + RecurseSubmodules: recurseSubmodules(c.RecurseSubmodules), Progress: nil, Tags: extgogit.AllTags, - CABundle: auth.CABundle, + CABundle: caBundle(opts), }) if err != nil { - return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, err) + return nil, fmt.Errorf("unable to clone '%s': %w", url, gitutil.GoGitError(err)) } repoTags, err := repo.Tags() if err != nil { - return nil, "", fmt.Errorf("git list tags error: %w", err) + return nil, fmt.Errorf("failed to list tags: %w", err) } tags := make(map[string]string) @@ -208,7 +231,7 @@ func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *g tags[t.Name().Short()] = t.Strings()[1] return nil }); err != nil { - return nil, "", err + return nil, err } var matchedVersions semver.Collection @@ -223,7 +246,7 @@ func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *g matchedVersions = append(matchedVersions, v) } if len(matchedVersions) == 0 { - return nil, "", fmt.Errorf("no match found for semver: %s", c.semVer) + return nil, fmt.Errorf("no match found for semver: %s", c.SemVer) } // Sort versions @@ -246,27 +269,61 @@ func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *g w, err := repo.Worktree() if err != nil { - return nil, "", fmt.Errorf("git worktree error: %w", err) + return nil, fmt.Errorf("failed to open Git worktree: %w", err) } + ref := plumbing.NewTagReferenceName(t) err = w.Checkout(&extgogit.CheckoutOptions{ - Branch: plumbing.NewTagReferenceName(t), + Branch: ref, }) if err != nil { - return nil, "", fmt.Errorf("git checkout error: %w", err) + return nil, fmt.Errorf("failed to checkout tag '%s': %w", t, err) } - head, err := repo.Head() if err != nil { - return nil, "", fmt.Errorf("git resolve HEAD error: %w", err) + return nil, fmt.Errorf("failed to resolve HEAD of tag '%s': %w", t, err) + } + cc, err := repo.CommitObject(head.Hash()) + if err != nil { + return nil, fmt.Errorf("failed to resolve commit object for HEAD '%s': %w", head.Hash(), err) + } + return buildCommitWithRef(cc, ref) +} + +func buildCommitWithRef(c *object.Commit, ref plumbing.ReferenceName) (*git.Commit, error) { + if c == nil { + return nil, errors.New("failed to construct commit: no object") } - commit, err := repo.CommitObject(head.Hash()) + // Encode commit components excluding signature into SignedData. + encoded := &plumbing.MemoryObject{} + if err := c.EncodeWithoutSignature(encoded); err != nil { + return nil, fmt.Errorf("failed to encode commit '%s': %w", c.Hash, err) + } + reader, err := encoded.Reader() if err != nil { - return nil, "", fmt.Errorf("git commit '%s' not found: %w", head.Hash(), err) + return nil, fmt.Errorf("failed to encode commit '%s': %w", c.Hash, err) } + b, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read encoded commit '%s': %w", c.Hash, err) + } + return &git.Commit{ + Hash: []byte(c.Hash.String()), + Reference: ref.String(), + Author: buildSignature(c.Author), + Committer: buildSignature(c.Committer), + Signature: c.PGPSignature, + Encoded: b, + }, nil +} - return &Commit{commit}, fmt.Sprintf("%s/%s", t, head.Hash().String()), nil +func buildSignature(s object.Signature) git.Signature { + return git.Signature{ + Name: s.Name, + Email: s.Email, + When: s.When, + } } func recurseSubmodules(recurse bool) extgogit.SubmoduleRescursivity { diff --git a/pkg/git/gogit/checkout_test.go b/pkg/git/gogit/checkout_test.go index eaa12c556..946dd9c5d 100644 --- a/pkg/git/gogit/checkout_test.go +++ b/pkg/git/gogit/checkout_test.go @@ -18,37 +18,420 @@ package gogit import ( "context" + "errors" "os" + "path/filepath" "testing" + "time" - "github.com/fluxcd/source-controller/pkg/git" + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-billy/v5/osfs" + extgogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/cache" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/storage/filesystem" + . "github.com/onsi/gomega" ) +func TestCheckoutBranch_Checkout(t *testing.T) { + repo, path, err := initRepo() + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(path) + + firstCommit, err := commitFile(repo, "branch", "init", time.Now()) + if err != nil { + t.Fatal(err) + } + + if err = createBranch(repo, "test"); err != nil { + t.Fatal(err) + } + + secondCommit, err := commitFile(repo, "branch", "second", time.Now()) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + branch string + expectedCommit string + expectedErr string + }{ + { + name: "Default branch", + branch: "master", + expectedCommit: firstCommit.String(), + }, + { + name: "Other branch", + branch: "test", + expectedCommit: secondCommit.String(), + }, + { + name: "Non existing branch", + branch: "invalid", + expectedErr: "couldn't find remote ref \"refs/heads/invalid\"", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + branch := CheckoutBranch{ + Branch: tt.branch, + } + tmpDir, _ := os.MkdirTemp("", "test") + defer os.RemoveAll(tmpDir) + + cc, err := branch.Checkout(context.TODO(), tmpDir, path, nil) + if tt.expectedErr != "" { + g.Expect(err.Error()).To(ContainSubstring(tt.expectedErr)) + g.Expect(cc).To(BeNil()) + return + } + g.Expect(err).To(BeNil()) + g.Expect(cc.String()).To(Equal(tt.branch + "/" + tt.expectedCommit)) + }) + } +} + +func TestCheckoutTag_Checkout(t *testing.T) { + tests := []struct { + name string + tag string + annotated bool + checkoutTag string + expectTag string + expectErr string + }{ + { + name: "Tag", + tag: "tag-1", + checkoutTag: "tag-1", + expectTag: "tag-1", + }, + { + name: "Annotated", + tag: "annotated", + annotated: true, + checkoutTag: "annotated", + expectTag: "annotated", + }, + { + name: "Non existing tag", + tag: "tag-1", + checkoutTag: "invalid", + expectErr: "couldn't find remote ref \"refs/tags/invalid\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + repo, path, err := initRepo() + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(path) + + var h plumbing.Hash + if tt.tag != "" { + h, err = commitFile(repo, "tag", tt.tag, time.Now()) + if err != nil { + t.Fatal(err) + } + _, err = tag(repo, h, !tt.annotated, tt.tag, time.Now()) + if err != nil { + t.Fatal(err) + } + } + + tag := CheckoutTag{ + Tag: tt.checkoutTag, + } + tmpDir, _ := os.MkdirTemp("", "test") + defer os.RemoveAll(tmpDir) + + cc, err := tag.Checkout(context.TODO(), tmpDir, path, nil) + if tt.expectErr != "" { + g.Expect(err.Error()).To(ContainSubstring(tt.expectErr)) + g.Expect(cc).To(BeNil()) + return + } + + g.Expect(err).To(BeNil()) + g.Expect(cc.String()).To(Equal(tt.expectTag + "/" + h.String())) + g.Expect(filepath.Join(tmpDir, "tag")).To(BeARegularFile()) + g.Expect(os.ReadFile(filepath.Join(tmpDir, "tag"))).To(BeEquivalentTo(tt.tag)) + }) + } +} + +func TestCheckoutCommit_Checkout(t *testing.T) { + repo, path, err := initRepo() + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(path) + + firstCommit, err := commitFile(repo, "commit", "init", time.Now()) + if err != nil { + t.Fatal(err) + } + if err = createBranch(repo, "other-branch"); err != nil { + t.Fatal(err) + } + secondCommit, err := commitFile(repo, "commit", "second", time.Now()) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + commit string + branch string + expectCommit string + expectFile string + expectError string + }{ + { + name: "Commit", + commit: firstCommit.String(), + expectCommit: "HEAD/" + firstCommit.String(), + expectFile: "init", + }, + { + name: "Commit in specific branch", + commit: secondCommit.String(), + branch: "other-branch", + expectCommit: "other-branch/" + secondCommit.String(), + expectFile: "second", + }, + { + name: "Non existing commit", + commit: "a-random-invalid-commit", + expectError: "failed to resolve commit object for 'a-random-invalid-commit': object not found", + }, + { + name: "Non existing commit in specific branch", + commit: secondCommit.String(), + branch: "master", + expectError: "object not found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + commit := CheckoutCommit{ + Commit: tt.commit, + Branch: tt.branch, + } + + tmpDir, err := os.MkdirTemp("", "git2go") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + cc, err := commit.Checkout(context.TODO(), tmpDir, path, nil) + if tt.expectError != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.expectError)) + g.Expect(cc).To(BeNil()) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cc).ToNot(BeNil()) + g.Expect(cc.String()).To(Equal(tt.expectCommit)) + g.Expect(filepath.Join(tmpDir, "commit")).To(BeARegularFile()) + g.Expect(os.ReadFile(filepath.Join(tmpDir, "commit"))).To(BeEquivalentTo(tt.expectFile)) + }) + } +} + func TestCheckoutTagSemVer_Checkout(t *testing.T) { - auth := &git.Auth{} - tag := CheckoutTag{ - tag: "v1.7.0", + now := time.Now() + + tags := []struct { + tag string + annotated bool + commitTime time.Time + tagTime time.Time + }{ + { + tag: "v0.0.1", + annotated: false, + commitTime: now, + }, + { + tag: "v0.1.0+build-1", + annotated: true, + commitTime: now.Add(10 * time.Minute), + tagTime: now.Add(2 * time.Hour), // This should be ignored during TS comparisons + }, + { + tag: "v0.1.0+build-2", + annotated: false, + commitTime: now.Add(30 * time.Minute), + }, + { + tag: "v0.1.0+build-3", + annotated: true, + commitTime: now.Add(1 * time.Hour), + tagTime: now.Add(1 * time.Hour), // This should be ignored during TS comparisons + }, + { + tag: "0.2.0", + annotated: true, + commitTime: now, + tagTime: now, + }, + } + tests := []struct { + name string + constraint string + expectErr error + expectTag string + }{ + { + name: "Orders by SemVer", + constraint: ">0.1.0", + expectTag: "0.2.0", + }, + { + name: "Orders by SemVer and timestamp", + constraint: "<0.2.0", + expectTag: "v0.1.0+build-3", + }, + { + name: "Errors without match", + constraint: ">=1.0.0", + expectErr: errors.New("no match found for semver: >=1.0.0"), + }, + } + + repo, path, err := initRepo() + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(path) + + refs := make(map[string]string, len(tags)) + for _, tt := range tags { + ref, err := commitFile(repo, "tag", tt.tag, tt.commitTime) + if err != nil { + t.Fatal(err) + } + _, err = tag(repo, ref, tt.annotated, tt.tag, tt.tagTime) + if err != nil { + t.Fatal(err) + } + refs[tt.tag] = ref.String() + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + semVer := CheckoutSemVer{ + SemVer: tt.constraint, + } + tmpDir, _ := os.MkdirTemp("", "test") + defer os.RemoveAll(tmpDir) + + cc, err := semVer.Checkout(context.TODO(), tmpDir, path, nil) + if tt.expectErr != nil { + g.Expect(err).To(Equal(tt.expectErr)) + g.Expect(cc).To(BeNil()) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cc.String()).To(Equal(tt.expectTag + "/" + refs[tt.expectTag])) + g.Expect(filepath.Join(tmpDir, "tag")).To(BeARegularFile()) + g.Expect(os.ReadFile(filepath.Join(tmpDir, "tag"))).To(BeEquivalentTo(tt.expectTag)) + }) } - tmpDir, _ := os.MkdirTemp("", "test") - defer os.RemoveAll(tmpDir) +} - cTag, _, err := tag.Checkout(context.TODO(), tmpDir, "https://github.com/projectcontour/contour", auth) +func initRepo() (*extgogit.Repository, string, error) { + tmpDir, err := os.MkdirTemp("", "gogit") if err != nil { - t.Error(err) + os.RemoveAll(tmpDir) + return nil, "", err } + sto := filesystem.NewStorage(osfs.New(tmpDir), cache.NewObjectLRUDefault()) + repo, err := extgogit.Init(sto, memfs.New()) + if err != nil { + os.RemoveAll(tmpDir) + return nil, "", err + } + return repo, tmpDir, err +} - semVer := CheckoutSemVer{ - semVer: ">=1.0.0 <=1.7.0", +func createBranch(repo *extgogit.Repository, branch string) error { + wt, err := repo.Worktree() + if err != nil { + return err + } + h, err := repo.Head() + if err != nil { + return err } - tmpDir2, _ := os.MkdirTemp("", "test") - defer os.RemoveAll(tmpDir2) + return wt.Checkout(&extgogit.CheckoutOptions{ + Hash: h.Hash(), + Branch: plumbing.ReferenceName("refs/heads/" + branch), + Create: true, + }) +} - cSemVer, _, err := semVer.Checkout(context.TODO(), tmpDir2, "https://github.com/projectcontour/contour", auth) +func commitFile(repo *extgogit.Repository, path, content string, time time.Time) (plumbing.Hash, error) { + wt, err := repo.Worktree() if err != nil { - t.Error(err) + return plumbing.Hash{}, err } + f, err := wt.Filesystem.Create(path) + if err != nil { + return plumbing.Hash{}, err + } + if _, err = f.Write([]byte(content)); err != nil { + f.Close() + return plumbing.Hash{}, err + } + if err = f.Close(); err != nil { + return plumbing.Hash{}, err + } + if _, err = wt.Add(path); err != nil { + return plumbing.Hash{}, err + } + return wt.Commit("Adding: "+path, &extgogit.CommitOptions{ + Author: mockSignature(time), + Committer: mockSignature(time), + }) +} + +func tag(repo *extgogit.Repository, commit plumbing.Hash, annotated bool, tag string, time time.Time) (*plumbing.Reference, error) { + var opts *extgogit.CreateTagOptions + if annotated { + opts = &extgogit.CreateTagOptions{ + Tagger: mockSignature(time), + Message: "Annotated tag for: " + tag, + } + } + return repo.CreateTag(tag, commit, opts) +} - if cTag.Hash() != cSemVer.Hash() { - t.Errorf("expected semver hash %s, got %s", cTag.Hash(), cSemVer.Hash()) +func mockSignature(time time.Time) *object.Signature { + return &object.Signature{ + Name: "Jane Doe", + Email: "jane@example.com", + When: time, } } diff --git a/pkg/git/gogit/commit.go b/pkg/git/gogit/commit.go deleted file mode 100644 index 8c14fea4e..000000000 --- a/pkg/git/gogit/commit.go +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2020 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 gogit - -import ( - "fmt" - - "github.com/go-git/go-git/v5/plumbing/object" - corev1 "k8s.io/api/core/v1" -) - -type Commit struct { - commit *object.Commit -} - -func (c *Commit) Hash() string { - return c.commit.Hash.String() -} - -// Verify returns an error if the PGP signature can't be verified -func (c *Commit) Verify(secret corev1.Secret) error { - if c.commit.PGPSignature == "" { - return fmt.Errorf("no PGP signature found for commit: %s", c.commit.Hash) - } - - var verified bool - for _, bytes := range secret.Data { - if _, err := c.commit.Verify(string(bytes)); err == nil { - verified = true - break - } - } - if !verified { - return fmt.Errorf("PGP signature '%s' of '%s' can't be verified", c.commit.PGPSignature, c.commit.Author) - } - return nil -} diff --git a/pkg/git/gogit/gogit.go b/pkg/git/gogit/gogit.go new file mode 100644 index 000000000..2ce0a8649 --- /dev/null +++ b/pkg/git/gogit/gogit.go @@ -0,0 +1,23 @@ +/* +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 gogit + +import "github.com/fluxcd/source-controller/pkg/git" + +const ( + Implementation git.Implementation = "go-git" +) diff --git a/pkg/git/gogit/transport.go b/pkg/git/gogit/transport.go index 5003fdd49..0ab3fbd67 100644 --- a/pkg/git/gogit/transport.go +++ b/pkg/git/gogit/transport.go @@ -18,87 +18,55 @@ package gogit import ( "fmt" - "net/url" + "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/ssh" - corev1 "k8s.io/api/core/v1" "github.com/fluxcd/pkg/ssh/knownhosts" "github.com/fluxcd/source-controller/pkg/git" ) -func AuthSecretStrategyForURL(URL string) (git.AuthSecretStrategy, error) { - u, err := url.Parse(URL) - if err != nil { - return nil, fmt.Errorf("failed to parse URL to determine auth strategy: %w", err) +// transportAuth constructs the transport.AuthMethod for the git.Transport of +// the given git.AuthOptions. It returns the result, or an error. +func transportAuth(opts *git.AuthOptions) (transport.AuthMethod, error) { + if opts == nil { + return nil, nil } - - switch { - case u.Scheme == "http", u.Scheme == "https": - return &BasicAuth{}, nil - case u.Scheme == "ssh": - return &PublicKeyAuth{user: u.User.Username()}, nil + switch opts.Transport { + case git.HTTPS, git.HTTP: + return &http.BasicAuth{ + Username: opts.Username, + Password: opts.Password, + }, nil + case git.SSH: + if len(opts.Identity) > 0 { + pk, err := ssh.NewPublicKeys(opts.Username, opts.Identity, opts.Password) + 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 + } + case "": + return nil, fmt.Errorf("no transport type set") default: - return nil, fmt.Errorf("no auth secret strategy for scheme %s", u.Scheme) - } -} - -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 nil, fmt.Errorf("unknown transport '%s'", opts.Transport) } - return auth, nil + return nil, 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 +// caBundle returns the CA bundle from the given git.AuthOptions. +func caBundle(opts *git.AuthOptions) []byte { + if opts == nil { + return nil } - - 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 + return opts.CAFile } diff --git a/pkg/git/gogit/transport_test.go b/pkg/git/gogit/transport_test.go index 591a040a3..93ea279d9 100644 --- a/pkg/git/gogit/transport_test.go +++ b/pkg/git/gogit/transport_test.go @@ -17,19 +17,21 @@ limitations under the License. package gogit import ( - "reflect" + "errors" "testing" + "github.com/go-git/go-git/v5/plumbing/transport" "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" ) const ( - // secretKeyFixture is a randomly generated password less + // privateKeyFixture is a randomly generated password less // 512bit RSA private key. - secretKeyFixture string = `-----BEGIN RSA PRIVATE KEY----- + privateKeyFixture = `-----BEGIN RSA PRIVATE KEY----- MIICXAIBAAKBgQCrakELAKxozvwJijQEggYlTvS1QTZx1DaBwOhW/4kRSuR21plu xuQeyuUiztoWeb9jgW7wjzG4j1PIJjdbsgjPIcIZ4PBY7JeEW+QRopfwuN8MHXNp uTLgIHbkmhoOg5qBEcjzO/lEOOPpV0EmbObgqv3+wRmLJrgfzWl/cTtRewIDAQAB @@ -45,9 +47,9 @@ Ngkgu4mLjc3RfenEhJECQAx8zjWUE6kHHPGAd9DfiAIQ4bChqnyS0Nwb9+Gd4hSE P0Ah10mHiK/M0o3T8Eanwum0gbQHPnOwqZgsPkwXRqQ= -----END RSA PRIVATE KEY-----` - // secretKeyFixture is a randomly generated + // privateKeyPassphraseFixture is a randomly generated // 512bit RSA private key with password foobar. - secretPassphraseFixture = `-----BEGIN RSA PRIVATE KEY----- + privateKeyPassphraseFixture = `-----BEGIN RSA PRIVATE KEY----- Proc-Type: 4,ENCRYPTED DEK-Info: AES-256-CBC,0B016973B2A761D31E6B388D0F327C35 @@ -60,138 +62,145 @@ wGctSx4kHsZGhJv5qwKqqPEFPhUzph8D2tm2TABk8HJa5KJFDbGrcfvk2uODAoZr MbcpIxCfl8oB09bWfY6tDQjyvwSYYo2Phdwm7kT92xc= -----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 // format. knownHostsFixture string = `github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==` ) -var ( - basicAuthSecretFixture = corev1.Secret{ - Data: map[string][]byte{ - "username": []byte("git"), - "password": []byte("password"), +func Test_transportAuth(t *testing.T) { + tests := []struct { + name string + opts *git.AuthOptions + wantFunc func(g *WithT, t transport.AuthMethod, opts *git.AuthOptions) + wantErr error + }{ + { + name: "HTTP basic auth", + opts: &git.AuthOptions{ + Transport: git.HTTP, + 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, + })) + }, }, - } - privateKeySecretFixture = corev1.Secret{ - Data: map[string][]byte{ - "identity": []byte(secretKeyFixture), - "known_hosts": []byte(knownHostsFixture), + { + 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, + })) + }, }, - } - privateKeySecretWithPassphraseFixture = corev1.Secret{ - Data: map[string][]byte{ - "identity": []byte(secretPassphraseFixture), - "known_hosts": []byte(knownHostsFixture), - "password": []byte("foobar"), + { + 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")) + }, }, - } - failingPrivateKey = corev1.Secret{ - Data: map[string][]byte{ - "identity": []byte(secretEDCSAFicture), - "known_hosts": []byte(knownHostsFixture), - "password": []byte("password"), + { + 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{}, + wantErr: errors.New("no transport type set"), + }, + { + name: "Unknown transport", + opts: &git.AuthOptions{ + Transport: "foo", + }, + wantErr: errors.New("unknown transport 'foo'"), }, - } -) - -func TestAuthSecretStrategyForURL(t *testing.T) { - tests := []struct { - name string - url string - want git.AuthSecretStrategy - wantErr bool - }{ - {"HTTP", "http://git.example.com/org/repo.git", &BasicAuth{}, false}, - {"HTTPS", "https://git.example.com/org/repo.git", &BasicAuth{}, false}, - {"SSH", "ssh://git.example.com:2222/org/repo.git", &PublicKeyAuth{}, false}, - {"SSH with username", "ssh://example@git.example.com:2222/org/repo.git", &PublicKeyAuth{user: "example"}, false}, - {"unsupported", "protocol://example.com", nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := AuthSecretStrategyForURL(tt.url) - if (err != nil) != tt.wantErr { - t.Errorf("AuthSecretStrategyForURL() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("AuthSecretStrategyForURL() got = %v, want %v", got, tt.want) - } - }) - } -} + g := NewWithT(t) -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) + got, err := transportAuth(tt.opts) + if tt.wantErr != nil { + g.Expect(err).To(Equal(tt.wantErr)) + g.Expect(got).To(BeNil()) return } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Method() got = %v, want %v", got, tt.want) + g.Expect(err).ToNot(HaveOccurred()) + if tt.wantFunc != nil { + tt.wantFunc(g, got, tt.opts) } }) } } -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 - } - }) - } +func Test_caBundle(t *testing.T) { + g := NewWithT(t) + + g.Expect(caBundle(&git.AuthOptions{CAFile: []byte("foo")})).To(BeEquivalentTo("foo")) + g.Expect(caBundle(nil)).To(BeNil()) } diff --git a/pkg/git/libgit2/checkout.go b/pkg/git/libgit2/checkout.go index 5fe28e92d..3d60d9c78 100644 --- a/pkg/git/libgit2/checkout.go +++ b/pkg/git/libgit2/checkout.go @@ -24,142 +24,135 @@ import ( "time" "github.com/Masterminds/semver/v3" + "github.com/go-logr/logr" git2go "github.com/libgit2/git2go/v31" "github.com/fluxcd/pkg/gitutil" "github.com/fluxcd/pkg/version" - sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" "github.com/fluxcd/source-controller/pkg/git" ) -func CheckoutStrategyForRef(ref *sourcev1.GitRepositoryRef, opt git.CheckoutOptions) git.CheckoutStrategy { +// CheckoutStrategyForOptions returns the git.CheckoutStrategy for the given +// git.CheckoutOptions. +func CheckoutStrategyForOptions(ctx context.Context, opt git.CheckoutOptions) git.CheckoutStrategy { + if opt.RecurseSubmodules { + logr.FromContextOrDiscard(ctx).Info("git submodule recursion not supported by '%s'", Implementation) + } switch { - case ref == nil: - return &CheckoutBranch{branch: git.DefaultBranch} - case ref.SemVer != "": - return &CheckoutSemVer{semVer: ref.SemVer} - case ref.Tag != "": - return &CheckoutTag{tag: ref.Tag} - case ref.Commit != "": - strategy := &CheckoutCommit{branch: ref.Branch, commit: ref.Commit} - if strategy.branch == "" { - strategy.branch = git.DefaultBranch - } - return strategy - case ref.Branch != "": - return &CheckoutBranch{branch: ref.Branch} + case opt.Commit != "": + return &CheckoutCommit{Commit: opt.Commit} + case opt.SemVer != "": + return &CheckoutSemVer{SemVer: opt.SemVer} + case opt.Tag != "": + return &CheckoutTag{Tag: opt.Tag} default: - return &CheckoutBranch{branch: git.DefaultBranch} + branch := opt.Branch + if branch == "" { + branch = git.DefaultBranch + } + return &CheckoutBranch{Branch: branch} } } 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, error) { repo, err := git2go.Clone(url, path, &git2go.CloneOptions{ FetchOptions: &git2go.FetchOptions{ - DownloadTags: git2go.DownloadTagsNone, - RemoteCallbacks: git2go.RemoteCallbacks{ - CredentialsCallback: auth.CredCallback, - CertificateCheckCallback: auth.CertCallback, - }, + DownloadTags: git2go.DownloadTagsNone, + RemoteCallbacks: RemoteCallbacks(opts), }, - CheckoutBranch: c.branch, + CheckoutBranch: c.Branch, }) if err != nil { - return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, gitutil.LibGit2Error(err)) + return nil, fmt.Errorf("unable to clone: %w", gitutil.LibGit2Error(err)) } + defer repo.Free() head, err := repo.Head() if err != nil { - return nil, "", fmt.Errorf("git resolve HEAD error: %w", err) + return nil, fmt.Errorf("git resolve HEAD error: %w", err) } defer head.Free() - commit, err := repo.LookupCommit(head.Target()) + cc, err := repo.LookupCommit(head.Target()) if err != nil { - return nil, "", fmt.Errorf("git commit '%s' not found: %w", head.Target(), err) + return nil, fmt.Errorf("could not find commit '%s' in branch '%s': %w", head.Target(), c.Branch, err) } - return &Commit{commit}, fmt.Sprintf("%s/%s", c.branch, head.Target().String()), nil + defer cc.Free() + return buildCommit(cc, "refs/heads/"+c.Branch), nil } 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, error) { repo, err := git2go.Clone(url, path, &git2go.CloneOptions{ FetchOptions: &git2go.FetchOptions{ - DownloadTags: git2go.DownloadTagsAll, - RemoteCallbacks: git2go.RemoteCallbacks{ - CredentialsCallback: auth.CredCallback, - CertificateCheckCallback: auth.CertCallback, - }, + DownloadTags: git2go.DownloadTagsAll, + RemoteCallbacks: RemoteCallbacks(opts), }, }) if err != nil { - return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, gitutil.LibGit2Error(err)) + return nil, fmt.Errorf("unable to clone '%s': %w", url, gitutil.LibGit2Error(err)) } - commit, err := checkoutDetachedDwim(repo, c.tag) + defer repo.Free() + cc, err := checkoutDetachedDwim(repo, c.Tag) if err != nil { - return nil, "", err + return nil, err } - return &Commit{commit}, fmt.Sprintf("%s/%s", c.tag, commit.Id().String()), nil + defer cc.Free() + return buildCommit(cc, "refs/tags/"+c.Tag), nil } type CheckoutCommit struct { - branch string - 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, error) { repo, err := git2go.Clone(url, path, &git2go.CloneOptions{ FetchOptions: &git2go.FetchOptions{ - DownloadTags: git2go.DownloadTagsNone, - RemoteCallbacks: git2go.RemoteCallbacks{ - CredentialsCallback: auth.CredCallback, - CertificateCheckCallback: auth.CertCallback, - }, + DownloadTags: git2go.DownloadTagsNone, + RemoteCallbacks: RemoteCallbacks(opts), }, }) if err != nil { - return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, gitutil.LibGit2Error(err)) + return nil, fmt.Errorf("unable to clone '%s': %w", url, gitutil.LibGit2Error(err)) } - - oid, err := git2go.NewOid(c.commit) + defer repo.Free() + oid, err := git2go.NewOid(c.Commit) if err != nil { - return nil, "", fmt.Errorf("could not create oid for '%s': %w", c.commit, err) + return nil, fmt.Errorf("could not create oid for '%s': %w", c.Commit, err) } - commit, err := checkoutDetachedHEAD(repo, oid) + cc, err := checkoutDetachedHEAD(repo, oid) if err != nil { - return nil, "", fmt.Errorf("git checkout error: %w", err) + return nil, fmt.Errorf("git checkout error: %w", err) } - return &Commit{commit}, fmt.Sprintf("%s/%s", c.branch, commit.Id().String()), nil + return buildCommit(cc, ""), nil } type CheckoutSemVer struct { - semVer string + SemVer string } -func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *git.Auth) (git.Commit, string, error) { - verConstraint, err := semver.NewConstraint(c.semVer) +func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, opts *git.AuthOptions) (*git.Commit, error) { + verConstraint, err := semver.NewConstraint(c.SemVer) if err != nil { - return nil, "", fmt.Errorf("semver parse range error: %w", err) + return nil, fmt.Errorf("semver parse error: %w", err) } repo, err := git2go.Clone(url, path, &git2go.CloneOptions{ FetchOptions: &git2go.FetchOptions{ - DownloadTags: git2go.DownloadTagsAll, - RemoteCallbacks: git2go.RemoteCallbacks{ - CredentialsCallback: auth.CredCallback, - CertificateCheckCallback: auth.CertCallback, - }, + DownloadTags: git2go.DownloadTagsAll, + RemoteCallbacks: RemoteCallbacks(opts), }, }) if err != nil { - return nil, "", fmt.Errorf("unable to clone '%s', error: %w", url, gitutil.LibGit2Error(err)) + return nil, fmt.Errorf("unable to clone '%s': %w", url, gitutil.LibGit2Error(err)) } + defer repo.Free() tags := make(map[string]string) tagTimestamps := make(map[string]time.Time) @@ -194,7 +187,7 @@ func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *g tags[t.Name()] = name return nil }); err != nil { - return nil, "", err + return nil, err } var matchedVersions semver.Collection @@ -209,7 +202,7 @@ func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *g matchedVersions = append(matchedVersions, v) } if len(matchedVersions) == 0 { - return nil, "", fmt.Errorf("no match found for semver: %s", c.semVer) + return nil, fmt.Errorf("no match found for semver: %s", c.SemVer) } // Sort versions @@ -230,8 +223,12 @@ func (c *CheckoutSemVer) Checkout(ctx context.Context, path, url string, auth *g v := matchedVersions[len(matchedVersions)-1] t := v.Original() - commit, err := checkoutDetachedDwim(repo, t) - return &Commit{commit}, fmt.Sprintf("%s/%s", t, commit.Id().String()), nil + cc, err := checkoutDetachedDwim(repo, t) + if err != nil { + return nil, err + } + defer cc.Free() + return buildCommit(cc, "refs/tags/"+t), nil } // checkoutDetachedDwim attempts to perform a detached HEAD checkout by first DWIMing the short name @@ -247,31 +244,31 @@ func checkoutDetachedDwim(repo *git2go.Repository, name string) (*git2go.Commit, return nil, fmt.Errorf("could not get commit for ref '%s': %w", ref.Name(), err) } defer c.Free() - commit, err := c.AsCommit() + cc, err := c.AsCommit() if err != nil { return nil, fmt.Errorf("could not get commit object for ref '%s': %w", ref.Name(), err) } - defer commit.Free() - return checkoutDetachedHEAD(repo, commit.Id()) + defer cc.Free() + return checkoutDetachedHEAD(repo, cc.Id()) } // checkoutDetachedHEAD attempts to perform a detached HEAD checkout for the given commit. func checkoutDetachedHEAD(repo *git2go.Repository, oid *git2go.Oid) (*git2go.Commit, error) { - commit, err := repo.LookupCommit(oid) + cc, err := repo.LookupCommit(oid) if err != nil { return nil, fmt.Errorf("git commit '%s' not found: %w", oid.String(), err) } - if err = repo.SetHeadDetached(commit.Id()); err != nil { - commit.Free() + if err = repo.SetHeadDetached(cc.Id()); err != nil { + cc.Free() return nil, fmt.Errorf("could not detach HEAD at '%s': %w", oid.String(), err) } if err = repo.CheckoutHead(&git2go.CheckoutOptions{ Strategy: git2go.CheckoutForce, }); err != nil { - commit.Free() + cc.Free() return nil, fmt.Errorf("git checkout error: %w", err) } - return commit, nil + return cc, nil } // headCommit returns the current HEAD of the repository, or an error. @@ -281,11 +278,30 @@ func headCommit(repo *git2go.Repository) (*git2go.Commit, error) { return nil, err } defer head.Free() - - commit, err := repo.LookupCommit(head.Target()) + c, err := repo.LookupCommit(head.Target()) if err != nil { return nil, err } + return c, nil +} + +func buildCommit(c *git2go.Commit, ref string) *git.Commit { + sig, msg, _ := c.ExtractSignature() + return &git.Commit{ + Hash: []byte(c.Id().String()), + Reference: ref, + Author: buildSignature(c.Author()), + Committer: buildSignature(c.Committer()), + Signature: sig, + Encoded: []byte(msg), + Message: c.Message(), + } +} - return commit, nil +func buildSignature(s *git2go.Signature) git.Signature { + return git.Signature{ + Name: s.Name, + Email: s.Email, + When: s.When, + } } diff --git a/pkg/git/libgit2/checkout_test.go b/pkg/git/libgit2/checkout_test.go index b7bf92935..24ca72b30 100644 --- a/pkg/git/libgit2/checkout_test.go +++ b/pkg/git/libgit2/checkout_test.go @@ -27,8 +27,6 @@ import ( git2go "github.com/libgit2/git2go/v31" . "github.com/onsi/gomega" - - "github.com/fluxcd/source-controller/pkg/git" ) func TestCheckoutBranch_Checkout(t *testing.T) { @@ -79,19 +77,20 @@ func TestCheckoutBranch_Checkout(t *testing.T) { g := NewWithT(t) branch := CheckoutBranch{ - branch: tt.branch, + Branch: tt.branch, } tmpDir, _ := os.MkdirTemp("", "test") defer os.RemoveAll(tmpDir) - _, ref, err := branch.Checkout(context.TODO(), tmpDir, repo.Path(), &git.Auth{}) + cc, err := branch.Checkout(context.TODO(), tmpDir, repo.Path(), nil) if tt.expectedErr != "" { + g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(ContainSubstring(tt.expectedErr)) - g.Expect(ref).To(BeEmpty()) + g.Expect(cc).To(BeNil()) return } - g.Expect(ref).To(Equal(tt.branch + "/" + tt.expectedCommit)) - g.Expect(err).To(BeNil()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cc.String()).To(Equal(tt.branch + "/" + tt.expectedCommit)) }) } } @@ -149,22 +148,23 @@ func TestCheckoutTag_Checkout(t *testing.T) { } tag := CheckoutTag{ - tag: tt.checkoutTag, + Tag: tt.checkoutTag, } tmpDir, _ := os.MkdirTemp("", "test") defer os.RemoveAll(tmpDir) - _, ref, err := tag.Checkout(context.TODO(), tmpDir, repo.Path(), &git.Auth{}) + cc, err := tag.Checkout(context.TODO(), tmpDir, repo.Path(), nil) if tt.expectErr != "" { - g.Expect(err.Error()).To(Equal(tt.expectErr)) - g.Expect(ref).To(BeEmpty()) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.expectErr)) + g.Expect(cc).To(BeNil()) return } - if tt.expectTag != "" { - g.Expect(ref).To(Equal(tt.expectTag + "/" + commit.Id().String())) - g.Expect(filepath.Join(tmpDir, "tag")).To(BeARegularFile()) - g.Expect(os.ReadFile(filepath.Join(tmpDir, "tag"))).To(BeEquivalentTo(tt.tag)) - } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cc.String()).To(Equal(tt.expectTag + "/" + commit.Id().String())) + g.Expect(filepath.Join(tmpDir, "tag")).To(BeARegularFile()) + g.Expect(os.ReadFile(filepath.Join(tmpDir, "tag"))).To(BeEquivalentTo(tt.tag)) }) } } @@ -188,27 +188,28 @@ func TestCheckoutCommit_Checkout(t *testing.T) { } commit := CheckoutCommit{ - commit: c.String(), - branch: "main", + Commit: c.String(), } tmpDir, _ := os.MkdirTemp("", "git2go") defer os.RemoveAll(tmpDir) - _, ref, err := commit.Checkout(context.TODO(), tmpDir, repo.Path(), &git.Auth{}) - g.Expect(err).To(BeNil()) - g.Expect(ref).To(Equal("main/" + c.String())) + cc, err := commit.Checkout(context.TODO(), tmpDir, repo.Path(), nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cc).ToNot(BeNil()) + g.Expect(cc.String()).To(Equal("HEAD/" + c.String())) g.Expect(filepath.Join(tmpDir, "commit")).To(BeARegularFile()) g.Expect(os.ReadFile(filepath.Join(tmpDir, "commit"))).To(BeEquivalentTo("init")) commit = CheckoutCommit{ - commit: "4dc3185c5fc94eb75048376edeb44571cece25f4", + Commit: "4dc3185c5fc94eb75048376edeb44571cece25f4", } tmpDir2, _ := os.MkdirTemp("", "git2go") defer os.RemoveAll(tmpDir) - _, ref, err = commit.Checkout(context.TODO(), tmpDir2, repo.Path(), &git.Auth{}) + cc, err = commit.Checkout(context.TODO(), tmpDir2, repo.Path(), nil) + g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(HavePrefix("git checkout error: git commit '4dc3185c5fc94eb75048376edeb44571cece25f4' not found:")) - g.Expect(ref).To(BeEmpty()) + g.Expect(cc).To(BeNil()) } func TestCheckoutTagSemVer_Checkout(t *testing.T) { @@ -307,19 +308,20 @@ func TestCheckoutTagSemVer_Checkout(t *testing.T) { g := NewWithT(t) semVer := CheckoutSemVer{ - semVer: tt.constraint, + SemVer: tt.constraint, } tmpDir, _ := os.MkdirTemp("", "test") defer os.RemoveAll(tmpDir) - _, ref, err := semVer.Checkout(context.TODO(), tmpDir, repo.Path(), &git.Auth{}) + cc, err := semVer.Checkout(context.TODO(), tmpDir, repo.Path(), nil) if tt.expectErr != nil { g.Expect(err).To(Equal(tt.expectErr)) - g.Expect(ref).To(BeEmpty()) + g.Expect(cc).To(BeNil()) return } + g.Expect(err).ToNot(HaveOccurred()) - g.Expect(ref).To(Equal(tt.expectTag + "/" + refs[tt.expectTag])) + g.Expect(cc.String()).To(Equal(tt.expectTag + "/" + refs[tt.expectTag])) g.Expect(filepath.Join(tmpDir, "tag")).To(BeARegularFile()) g.Expect(os.ReadFile(filepath.Join(tmpDir, "tag"))).To(BeEquivalentTo(tt.expectTag)) }) @@ -395,11 +397,11 @@ func commitFile(repo *git2go.Repository, path, content string, time time.Time) ( } defer tree.Free() - commit, err := repo.CreateCommit("HEAD", signature(time), signature(time), "Committing "+path, tree, parentC...) + c, err := repo.CreateCommit("HEAD", mockSignature(time), mockSignature(time), "Committing "+path, tree, parentC...) if err != nil { return nil, err } - return commit, nil + return c, nil } func tag(repo *git2go.Repository, cId *git2go.Oid, annotated bool, tag string, time time.Time) (*git2go.Oid, error) { @@ -408,12 +410,12 @@ func tag(repo *git2go.Repository, cId *git2go.Oid, annotated bool, tag string, t return nil, err } if annotated { - return repo.Tags.Create(tag, commit, signature(time), fmt.Sprintf("Annotated tag for %s", tag)) + return repo.Tags.Create(tag, commit, mockSignature(time), fmt.Sprintf("Annotated tag for %s", tag)) } return repo.Tags.CreateLightweight(tag, commit, false) } -func signature(time time.Time) *git2go.Signature { +func mockSignature(time time.Time) *git2go.Signature { return &git2go.Signature{ Name: "Jane Doe", Email: "author@example.com", diff --git a/pkg/git/libgit2/commit.go b/pkg/git/libgit2/commit.go deleted file mode 100644 index 1e459f319..000000000 --- a/pkg/git/libgit2/commit.go +++ /dev/null @@ -1,65 +0,0 @@ -/* -Copyright 2020 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 libgit2 - -import ( - "bytes" - "fmt" - "strings" - - "golang.org/x/crypto/openpgp" - - git2go "github.com/libgit2/git2go/v31" - corev1 "k8s.io/api/core/v1" -) - -type Commit struct { - commit *git2go.Commit -} - -func (c *Commit) Hash() string { - return c.commit.Id().String() -} - -// Verify returns an error if the PGP signature can't be verified -func (c *Commit) Verify(secret corev1.Secret) error { - signature, signedData, err := c.commit.ExtractSignature() - if err != nil { - return err - } - - var verified bool - for _, b := range secret.Data { - keyRingReader := strings.NewReader(string(b)) - keyring, err := openpgp.ReadArmoredKeyRing(keyRingReader) - if err != nil { - return err - } - - _, err = openpgp.CheckArmoredDetachedSignature(keyring, strings.NewReader(signedData), bytes.NewBufferString(signature)) - if err == nil { - verified = true - break - } - } - - if !verified { - return fmt.Errorf("PGP signature '%s' of '%s' can't be verified", signature, c.commit.Committer().Email) - } - - return nil -} diff --git a/pkg/git/libgit2/libgit2.go b/pkg/git/libgit2/libgit2.go new file mode 100644 index 000000000..e705e6b0a --- /dev/null +++ b/pkg/git/libgit2/libgit2.go @@ -0,0 +1,23 @@ +/* +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 libgit2 + +import "github.com/fluxcd/source-controller/pkg/git" + +const ( + Implementation git.Implementation = "libgit2" +) diff --git a/pkg/git/libgit2/transport.go b/pkg/git/libgit2/transport.go index 6329a490d..83d9107ec 100644 --- a/pkg/git/libgit2/transport.go +++ b/pkg/git/libgit2/transport.go @@ -26,137 +26,120 @@ import ( "fmt" "hash" "net" - "net/url" "strings" + "time" git2go "github.com/libgit2/git2go/v31" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" - corev1 "k8s.io/api/core/v1" "github.com/fluxcd/source-controller/pkg/git" ) -func AuthSecretStrategyForURL(URL string) (git.AuthSecretStrategy, error) { - u, err := url.Parse(URL) - if err != nil { - return nil, fmt.Errorf("failed to parse URL to determine auth strategy: %w", err) - } +var ( + now = time.Now +) - switch { - case u.Scheme == "http", u.Scheme == "https": - return &BasicAuth{}, nil - case u.Scheme == "ssh": - return &PublicKeyAuth{user: u.User.Username(), host: u.Host}, nil - default: - return nil, fmt.Errorf("no auth secret strategy for scheme %s", u.Scheme) +// RemoteCallbacks constructs RemoteCallbacks with credentialsCallback and +// certificateCallback, and the given options if the given opts is not nil. +func RemoteCallbacks(opts *git.AuthOptions) git2go.RemoteCallbacks { + if opts != nil { + return git2go.RemoteCallbacks{ + CredentialsCallback: credentialsCallback(opts), + CertificateCheckCallback: certificateCallback(opts), + } } + return git2go.RemoteCallbacks{} } -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) +// credentialsCallback constructs CredentialsCallbacks with the given options +// for git.Transport, and returns the result. +func credentialsCallback(opts *git.AuthOptions) git2go.CredentialsCallback { + return func(url string, username string, allowedTypes git2go.CredentialType) (*git2go.Credential, error) { + if allowedTypes&(git2go.CredentialTypeSSHKey|git2go.CredentialTypeSSHCustom|git2go.CredentialTypeSSHMemory) != 0 { + var ( + signer ssh.Signer + err error + ) + if opts.Password != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase(opts.Identity, []byte(opts.Password)) + } else { + signer, err = ssh.ParsePrivateKey(opts.Identity) + } if err != nil { return nil, err } - return cred, nil + return git2go.NewCredentialSSHKeyFromSigner(opts.Username, signer) } - } - - var certCallback git2go.CertificateCheckCallback - if caFile, ok := secret.Data[git.CAFile]; ok { - certCallback = func(cert *git2go.Certificate, valid bool, hostname string) git2go.ErrorCode { - roots := x509.NewCertPool() - ok := roots.AppendCertsFromPEM(caFile) - if !ok { - return git2go.ErrorCodeCertificate - } - - opts := x509.VerifyOptions{ - Roots: roots, - DNSName: hostname, - } - _, err := cert.X509.Verify(opts) - if err != nil { - return git2go.ErrorCodeCertificate - } - return git2go.ErrorCodeOK + if (allowedTypes & git2go.CredentialTypeUserpassPlaintext) != 0 { + return git2go.NewCredentialUserpassPlaintext(opts.Username, opts.Password) + } + if (allowedTypes & git2go.CredentialTypeUsername) != 0 { + return git2go.NewCredentialUsername(opts.Username) } + return nil, fmt.Errorf("unknown credential type %+v", allowedTypes) } - - return &git.Auth{CredCallback: credCallback, CertCallback: certCallback}, nil } -type PublicKeyAuth struct { - user string - host string -} - -func (s *PublicKeyAuth) Method(secret corev1.Secret) (*git.Auth, error) { - if _, ok := secret.Data[git.CAFile]; ok { - return nil, fmt.Errorf("found %s key in secret '%s' but libgit2 SSH transport does not support custom certificates", git.CAFile, 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) - } - - kk, err := parseKnownHosts(string(knownHosts)) - if err != nil { - return nil, err - } - - // Need to validate private key as it is not - // done by git2go when loading the key - password, ok := secret.Data["password"] - if ok { - _, err = ssh.ParsePrivateKeyWithPassphrase(identity, password) - } else { - _, err = ssh.ParsePrivateKey(identity) +// certificateCallback constructs CertificateCallback with the given options +// for git.Transport if the given opts is not nil, and returns the result. +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 +} - if err != nil { - return nil, err - } +// x509Callback returns a CertificateCheckCallback that verifies the +// certificate against the given caBundle for git.HTTPS Transports. +func x509Callback(caBundle []byte) git2go.CertificateCheckCallback { + return func(cert *git2go.Certificate, valid bool, hostname string) git2go.ErrorCode { + roots := x509.NewCertPool() + if ok := roots.AppendCertsFromPEM(caBundle); !ok { + return git2go.ErrorCodeCertificate + } - user := s.user - if user == "" { - user = git.DefaultPublicKeyAuthUser + opts := x509.VerifyOptions{ + Roots: roots, + DNSName: hostname, + CurrentTime: now(), + } + if _, err := cert.X509.Verify(opts); err != nil { + return git2go.ErrorCodeCertificate + } + return git2go.ErrorCodeOK } +} - credCallback := func(url string, usernameFromURL string, allowedTypes git2go.CredentialType) (*git2go.Credential, error) { - cred, err := git2go.NewCredentialSSHKeyFromMemory(user, "", string(identity), string(password)) +// knownHostCallback returns a CertificateCheckCallback that verifies +// the key of Git server against the given host and known_hosts for +// git.SSH Transports. +func knownHostsCallback(host string, knownHosts []byte) git2go.CertificateCheckCallback { + return func(cert *git2go.Certificate, valid bool, hostname string) git2go.ErrorCode { + kh, err := parseKnownHosts(string(knownHosts)) 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 // the port-less hostname given to the callback. - host, _, err := net.SplitHostPort(s.host) + h, _, err := net.SplitHostPort(host) if err != nil { // SplitHostPort returns an error if the host is missing // a port, assume the host has no port. - host = s.host + h = host } // Check if the configured host matches the hostname given to // the callback. - if host != hostname { + if h != hostname { return git2go.ErrorCodeUser } @@ -164,16 +147,14 @@ func (s *PublicKeyAuth) Method(secret corev1.Secret) (*git.Auth, error) { // given to the callback match. Use the configured host (that // includes the port), and normalize it, so we can check if there // is an entry for the hostname _and_ port. - host = knownhosts.Normalize(s.host) - for _, k := range kk { - if k.matches(host, cert.Hostkey) { + h = knownhosts.Normalize(host) + for _, k := range kh { + if k.matches(h, cert.Hostkey) { return git2go.ErrorCodeOK } } return git2go.ErrorCodeCertificate } - - return &git.Auth{CredCallback: credCallback, CertCallback: certCallback}, nil } type knownKey struct { @@ -234,6 +215,5 @@ func containsHost(hosts []string, host string) bool { return true } } - return false } diff --git a/pkg/git/libgit2/transport_test.go b/pkg/git/libgit2/transport_test.go index 733fa0c96..6f1e9545b 100644 --- a/pkg/git/libgit2/transport_test.go +++ b/pkg/git/libgit2/transport_test.go @@ -17,163 +17,241 @@ limitations under the License. package libgit2 import ( + "bytes" + "crypto/x509" "encoding/base64" - "reflect" + "encoding/pem" + "errors" "testing" + "time" git2go "github.com/libgit2/git2go/v31" - corev1 "k8s.io/api/core/v1" - - "github.com/fluxcd/source-controller/pkg/git" + . "github.com/onsi/gomega" ) const ( - // secretKeyFixture is a randomly generated password less - // 512bit RSA private key. - secretKeyFixture string = `-----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-----` - - // secretKeyFixture is a randomly generated - // 512bit RSA private key with password foobar. - secretPassphraseFixture = `-----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. + geoTrustRootFixture = `-----BEGIN CERTIFICATE----- +MIIDVDCCAjygAwIBAgIDAjRWMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT +MRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9i +YWwgQ0EwHhcNMDIwNTIxMDQwMDAwWhcNMjIwNTIxMDQwMDAwWjBCMQswCQYDVQQG +EwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEbMBkGA1UEAxMSR2VvVHJ1c3Qg +R2xvYmFsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2swYYzD9 +9BcjGlZ+W988bDjkcbd4kdS8odhM+KhDtgPpTSEHCIjaWC9mOSm9BXiLnTjoBbdq +fnGk5sRgprDvgOSJKA+eJdbtg/OtppHHmMlCGDUUna2YRpIuT8rxh0PBFpVXLVDv +iS2Aelet8u5fa9IAjbkU+BQVNdnARqN7csiRv8lVK83Qlz6cJmTM386DGXHKTubU +1XupGc1V3sjs0l44U+VcT4wt/lAjNvxm5suOpDkZALeVAjmRCw7+OC7RHQWa9k0+ +bw8HHa8sHo9gOeL6NlMTOdReJivbPagUvTLrGAMoUgRx5aszPeE4uwc2hGKceeoW +MPRfwCvocWvk+QIDAQABo1MwUTAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTA +ephojYn7qwVkDBF9qn1luMrMTjAfBgNVHSMEGDAWgBTAephojYn7qwVkDBF9qn1l +uMrMTjANBgkqhkiG9w0BAQUFAAOCAQEANeMpauUvXVSOKVCUn5kaFOSPeCpilKIn +Z57QzxpeR+nBsqTP3UEaBU6bS+5Kb1VSsyShNwrrZHYqLizz/Tt1kL/6cdjHPTfS +tQWVYrmm3ok9Nns4d0iXrKYgjy6myQzCsplFAMfOEVEiIuCl6rYVSAlk6l5PdPcF +PseKUgzbFbS9bZvlxrFUaKnjaZC2mqUPuLk/IH2uSrW4nOQdtqvmlKXBx4Ot2/Un +hw4EbNX/3aBd7YdStysVAq45pmp06drE57xNNB6pXE0zX5IJL4hmXXeXxx12E6nV +5fEWCRE11azbJHFwLJhWC9kXtNHjUStedejV0NxPNO3CBWaAocvmMw== +-----END CERTIFICATE-----` + + giag2IntermediateFixture = `-----BEGIN CERTIFICATE----- +MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT +MRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9i +YWwgQ0EwHhcNMTMwNDA1MTUxNTU1WhcNMTUwNDA0MTUxNTU1WjBJMQswCQYDVQQG +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-----` + + googleLeafFixture = `-----BEGIN CERTIFICATE----- +MIIEdjCCA16gAwIBAgIIcR5k4dkoe04wDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE +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 +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-----` + + // googleLeafWithInvalidHashFixture is the same as googleLeafFixture, but the signature + // algorithm in the certificate contains a nonsense OID. + 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-----` + knownHostsFixture string = `github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==` ) -var ( - 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"), - }, - } -) +func Test_x509Callback(t *testing.T) { + now = func() time.Time { return time.Unix(1395785200, 0) } -func TestAuthSecretStrategyForURL(t *testing.T) { tests := []struct { - name string - url string - want git.AuthSecretStrategy - wantErr bool + name string + certificate string + host string + 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}, - {"SSH", "ssh://git.example.com:2222/org/repo.git", &PublicKeyAuth{host: "git.example.com:2222"}, false}, - {"SSH with username", "ssh://example@git.example.com:2222/org/repo.git", &PublicKeyAuth{user: "example", host: "git.example.com:2222"}, false}, - {"unsupported", "protocol://example.com", nil, true}, + { + name: "Valid certificate authority bundle", + certificate: googleLeafFixture, + host: "www.google.com", + 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 { t.Run(tt.name, func(t *testing.T) { - got, err := AuthSecretStrategyForURL(tt.url) - if (err != nil) != tt.wantErr { - t.Errorf("AuthSecretStrategyForURL() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("AuthSecretStrategyForURL() got = %v, want %v", got, tt.want) - } - }) - } -} + g := NewWithT(t) -func TestBasicAuthStrategy_Method(t *testing.T) { - tests := []struct { - name string - secret corev1.Secret - modify func(secret *corev1.Secret) - wantErr bool - }{ - {"with username and password", basicAuthSecretFixture, nil, false}, - } - 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{} - _, err := s.Method(*secret) - if (err != nil) != tt.wantErr { - t.Errorf("Method() error = %v, wantErr %v", err, tt.wantErr) - return + cert := &git2go.Certificate{} + if tt.certificate != "" { + x509Cert, err := certificateFromPEM(tt.certificate) + g.Expect(err).ToNot(HaveOccurred()) + cert.X509 = x509Cert } + + callback := x509Callback(tt.caBundle) + g.Expect(callback(cert, false, tt.host)).To(Equal(tt.want)) }) } } -func TestPublicKeyStrategy_Method(t *testing.T) { +func Test_knownHostsCallback(t *testing.T) { tests := []struct { - name string - secret corev1.Secret - modify func(secret *corev1.Secret) - wantErr bool + name string + host string + expectedHost string + knownHosts []byte + hostkey git2go.HostkeyCertificate + want git2go.ErrorCode }{ - {"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}, + { + name: "Match", + host: "github.com", + knownHosts: []byte(knownHostsFixture), + 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(knownHostsFixture), + 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(knownHostsFixture), + 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(knownHostsFixture), + 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 { 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 - } + g := NewWithT(t) + + cert := &git2go.Certificate{Hostkey: tt.hostkey} + callback := knownHostsCallback(tt.expectedHost, tt.knownHosts) + g.Expect(callback(cert, false, tt.host)).To(Equal(tt.want)) }) } } -func TestKnownKeyHash(t *testing.T) { +func Test_parseKnownHosts(t *testing.T) { tests := []struct { name string hostkey git2go.HostkeyCertificate @@ -189,24 +267,22 @@ func TestKnownKeyHash(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + knownKeys, err := parseKnownHosts(knownHostsFixture) if err != nil { t.Error(err) return } - matches := knownKeys[0].matches("github.com", tt.hostkey) - if matches != tt.wantMatches { - t.Errorf("Method() matches = %v, wantMatches %v", matches, tt.wantMatches) - return - } + g.Expect(matches).To(Equal(tt.wantMatches)) }) } } func md5Fingerprint(in string) [16]byte { var out [16]byte - copy(out[:], []byte(in)) + copy(out[:], in) return out } @@ -229,3 +305,11 @@ func sha256Fingerprint(in string) [32]byte { copy(out[:], d) 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) +} diff --git a/pkg/git/options.go b/pkg/git/options.go new file mode 100644 index 000000000..64458f5ec --- /dev/null +++ b/pkg/git/options.go @@ -0,0 +1,131 @@ +/* +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" + + 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 { + // Branch to checkout, can be combined with Branch with some + // Implementations. + Branch string + + // Tag to checkout, takes precedence over Branch. + Tag string + + // SemVer tag expression to checkout, takes precedence over Tag. + SemVer string `json:"semver,omitempty"` + + // Commit SHA1 to checkout, takes precedence over Tag and SemVer, + // can be combined with Branch with some Implementations. + Commit string + + // RecurseSubmodules defines if submodules should be checked out, + // not supported by all Implementations. + 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 o.Host == "" { + return fmt.Errorf("invalid '%s' auth option: 'host' is required", o.Transport) + } + if len(o.Identity) == 0 { + return fmt.Errorf("invalid '%s' auth option: 'identity' is required", o.Transport) + } + 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") + default: + return fmt.Errorf("unknown transport '%s'", o.Transport) + } + 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 +} diff --git a/pkg/git/options_test.go b/pkg/git/options_test.go new file mode 100644 index 000000000..17defd94a --- /dev/null +++ b/pkg/git/options_test.go @@ -0,0 +1,272 @@ +/* +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: "Valid HTTP transport", + opts: AuthOptions{ + Transport: HTTP, + Username: "example", + Password: "foo", + }, + }, + { + 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: "Valid HTTPS transport", + opts: AuthOptions{ + Transport: HTTPS, + Username: "example", + Password: "foo", + }, + }, + { + name: "Valid HTTPS without any config", + opts: AuthOptions{ + Transport: HTTPS, + }, + }, + { + name: "SSH transport requires host", + opts: AuthOptions{ + Transport: SSH, + }, + wantErr: "invalid 'ssh' auth option: 'host' is required", + }, + { + name: "SSH transport requires identity", + opts: AuthOptions{ + Transport: SSH, + Host: "github.com:22", + }, + wantErr: "invalid 'ssh' auth option: 'identity' is required", + }, + { + name: "SSH transport requires known_hosts", + opts: AuthOptions{ + Transport: SSH, + Host: "github.com:22", + Identity: []byte(privateKeyFixture), + }, + wantErr: "invalid 'ssh' auth option: 'known_hosts' is required", + }, + { + name: "Requires transport", + opts: AuthOptions{}, + wantErr: "no transport type set", + }, + { + name: "Valid SSH transport", + opts: AuthOptions{ + Host: "github.com:22", + Transport: SSH, + Identity: []byte(privateKeyPassphraseFixture), + Password: "foobar", + KnownHosts: []byte(knownHostsFixture), + }, + }, + { + name: "No transport", + opts: AuthOptions{}, + wantErr: "no transport type set", + }, + { + name: "Unknown transport", + opts: AuthOptions{ + Transport: "foo", + }, + wantErr: "unknown transport 'foo'", + }, + } + 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) + } + }) + } +} diff --git a/pkg/git/strategy/strategy.go b/pkg/git/strategy/strategy.go index 6b3ea266d..46d4e58ae 100644 --- a/pkg/git/strategy/strategy.go +++ b/pkg/git/strategy/strategy.go @@ -17,32 +17,23 @@ limitations under the License. package strategy import ( + "context" "fmt" - sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" "github.com/fluxcd/source-controller/pkg/git" "github.com/fluxcd/source-controller/pkg/git/gogit" "github.com/fluxcd/source-controller/pkg/git/libgit2" ) -func CheckoutStrategyForRef(ref *sourcev1.GitRepositoryRef, opt git.CheckoutOptions) (git.CheckoutStrategy, error) { - switch opt.GitImplementation { - case sourcev1.GoGitImplementation: - return gogit.CheckoutStrategyForRef(ref, opt), nil - case sourcev1.LibGit2Implementation: - return libgit2.CheckoutStrategyForRef(ref, opt), nil +// CheckoutStrategyForImplementation returns the CheckoutStrategy for the given +// git.Implementation and git.CheckoutOptions. +func CheckoutStrategyForImplementation(ctx context.Context, impl git.Implementation, opts git.CheckoutOptions) (git.CheckoutStrategy, error) { + switch impl { + case gogit.Implementation: + return gogit.CheckoutStrategyForOptions(ctx, opts), nil + case libgit2.Implementation: + return libgit2.CheckoutStrategyForOptions(ctx, opts), nil default: - return nil, fmt.Errorf("invalid 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) + return nil, fmt.Errorf("unsupported Git implementation '%s'", impl) } } diff --git a/pkg/git/strategy/strategy_test.go b/pkg/git/strategy/strategy_test.go new file mode 100644 index 000000000..f88563a98 --- /dev/null +++ b/pkg/git/strategy/strategy_test.go @@ -0,0 +1,403 @@ +/* +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 strategy + +import ( + "context" + "errors" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/fluxcd/pkg/gittestserver" + "github.com/fluxcd/pkg/ssh" + extgogit "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/object" + . "github.com/onsi/gomega" + + "github.com/fluxcd/source-controller/pkg/git" + "github.com/fluxcd/source-controller/pkg/git/gogit" + "github.com/fluxcd/source-controller/pkg/git/libgit2" +) + +func TestCheckoutStrategyForImplementation_Auth(t *testing.T) { + gitImpls := []git.Implementation{gogit.Implementation, libgit2.Implementation} + + type testCase struct { + name string + transport git.TransportType + repoURLFunc func(g *WithT, srv *gittestserver.GitServer, repoPath string) string + authOptsFunc func(g *WithT, u *url.URL, user string, pswd string, ca []byte) *git.AuthOptions + wantFunc func(g *WithT, cs git.CheckoutStrategy, dir string, repoURL string, authOpts *git.AuthOptions) + } + + cases := []testCase{ + { + name: "HTTP clone", + transport: git.HTTP, + repoURLFunc: func(g *WithT, srv *gittestserver.GitServer, repoPath string) string { + return srv.HTTPAddressWithCredentials() + "/" + repoPath + }, + authOptsFunc: func(g *WithT, u *url.URL, user string, pswd string, ca []byte) *git.AuthOptions { + return &git.AuthOptions{ + Transport: git.HTTP, + Username: user, + Password: pswd, + } + }, + wantFunc: func(g *WithT, cs git.CheckoutStrategy, dir string, repoURL string, authOpts *git.AuthOptions) { + _, err := cs.Checkout(context.TODO(), dir, repoURL, authOpts) + g.Expect(err).ToNot(HaveOccurred()) + }, + }, + { + name: "HTTPS clone", + transport: git.HTTPS, + repoURLFunc: func(g *WithT, srv *gittestserver.GitServer, repoPath string) string { + return srv.HTTPAddress() + "/" + repoPath + }, + authOptsFunc: func(g *WithT, u *url.URL, user, pswd string, ca []byte) *git.AuthOptions { + return &git.AuthOptions{ + Transport: git.HTTPS, + Username: user, + Password: pswd, + CAFile: ca, + } + }, + wantFunc: func(g *WithT, cs git.CheckoutStrategy, dir, repoURL string, authOpts *git.AuthOptions) { + _, err := cs.Checkout(context.TODO(), dir, repoURL, authOpts) + g.Expect(err).ToNot(HaveOccurred()) + }, + }, + { + name: "SSH clone", + transport: git.SSH, + repoURLFunc: func(g *WithT, srv *gittestserver.GitServer, repoPath string) string { + return getSSHRepoURL(srv.SSHAddress(), repoPath) + }, + authOptsFunc: func(g *WithT, u *url.URL, user, pswd string, ca []byte) *git.AuthOptions { + knownhosts, err := ssh.ScanHostKey(u.Host, 5*time.Second) + g.Expect(err).ToNot(HaveOccurred()) + + keygen := ssh.NewRSAGenerator(2048) + pair, err := keygen.Generate() + g.Expect(err).ToNot(HaveOccurred()) + + return &git.AuthOptions{ + Host: u.Host, // Without this libgit2 returns error "user cancelled hostkey check". + Transport: git.SSH, + Username: "git", // Without this libgit2 returns error "username does not match previous request". + Identity: pair.PrivateKey, + KnownHosts: knownhosts, + } + }, + wantFunc: func(g *WithT, cs git.CheckoutStrategy, dir, repoURL string, authOpts *git.AuthOptions) { + _, err := cs.Checkout(context.TODO(), dir, repoURL, authOpts) + g.Expect(err).ToNot(HaveOccurred()) + }, + }, + } + + testFunc := func(tt testCase, impl git.Implementation) func(t *testing.T) { + return func(t *testing.T) { + g := NewWithT(t) + + var examplePublicKey, examplePrivateKey, exampleCA []byte + + gitServer, err := gittestserver.NewTempGitServer() + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(gitServer.Root()) + + username := "test-user" + password := "test-password" + gitServer.Auth(username, password) + gitServer.KeyDir(gitServer.Root()) + + // Start the HTTP/HTTPS server. + if tt.transport == git.HTTPS { + var err error + examplePublicKey, err = os.ReadFile("testdata/certs/server.pem") + g.Expect(err).ToNot(HaveOccurred()) + examplePrivateKey, err = os.ReadFile("testdata/certs/server-key.pem") + g.Expect(err).ToNot(HaveOccurred()) + exampleCA, err = os.ReadFile("testdata/certs/ca.pem") + g.Expect(err).ToNot(HaveOccurred()) + err = gitServer.StartHTTPS(examplePublicKey, examplePrivateKey, exampleCA, "example.com") + g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(gitServer.StartHTTP()).ToNot(HaveOccurred()) + } + + defer gitServer.StopHTTP() + + // Start the SSH server. + if tt.transport == git.SSH { + g.Expect(gitServer.ListenSSH()).ToNot(HaveOccurred()) + go func() { + gitServer.StartSSH() + }() + defer func() { + g.Expect(gitServer.StopSSH()).To(Succeed()) + }() + } + + // Initialize a git repo. + branch := "main" + repoPath := "bar/test-reponame" + err = gitServer.InitRepo("testdata/repo1", branch, repoPath) + g.Expect(err).ToNot(HaveOccurred()) + + repoURL := tt.repoURLFunc(g, gitServer, repoPath) + u, err := url.Parse(repoURL) + g.Expect(err).ToNot(HaveOccurred()) + authOpts := tt.authOptsFunc(g, u, username, password, exampleCA) + + // Get the checkout strategy. + checkoutOpts := git.CheckoutOptions{ + Branch: branch, + } + checkoutStrategy, err := CheckoutStrategyForImplementation(context.TODO(), impl, checkoutOpts) + g.Expect(err).ToNot(HaveOccurred()) + + tmpDir, err := os.MkdirTemp("", "test-checkout") + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(tmpDir) + + tt.wantFunc(g, checkoutStrategy, tmpDir, repoURL, authOpts) + } + } + + // Run the test cases against the git implementations. + for _, gitImpl := range gitImpls { + for _, tt := range cases { + t.Run(string(gitImpl)+"_"+tt.name, testFunc(tt, gitImpl)) + } + } +} + +func getSSHRepoURL(sshAddress, repoPath string) string { + // This is expected to use 127.0.0.1, but host key + // checking usually wants a hostname, so use + // "localhost". + sshURL := strings.Replace(sshAddress, "127.0.0.1", "localhost", 1) + return sshURL + "/" + repoPath +} + +func TestCheckoutStrategyForImplementation_SemVerCheckout(t *testing.T) { + g := NewWithT(t) + + gitImpls := []git.Implementation{gogit.Implementation, libgit2.Implementation} + + // Setup git server and repo. + gitServer, err := gittestserver.NewTempGitServer() + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(gitServer.Root()) + username := "test-user" + password := "test-password" + gitServer.Auth(username, password) + gitServer.KeyDir(gitServer.Root()) + g.Expect(gitServer.StartHTTP()).ToNot(HaveOccurred()) + defer gitServer.StopHTTP() + + repoPath := "bar/test-reponame" + err = gitServer.InitRepo("testdata/repo1", "main", repoPath) + g.Expect(err).ToNot(HaveOccurred()) + + repoURL := gitServer.HTTPAddressWithCredentials() + "/" + repoPath + + authOpts := &git.AuthOptions{ + Transport: git.HTTP, + Username: username, + Password: password, + } + + // Create test tags in the repo. + now := time.Now() + tags := []struct { + tag string + annotated bool + commitTime time.Time + tagTime time.Time + }{ + { + tag: "v0.0.1", + annotated: false, + commitTime: now, + }, + { + tag: "v0.1.0+build-1", + annotated: true, + commitTime: now.Add(10 * time.Minute), + tagTime: now.Add(2 * time.Hour), // This should be ignored during TS comparisons + }, + { + tag: "v0.1.0+build-2", + annotated: false, + commitTime: now.Add(30 * time.Minute), + }, + { + tag: "v0.1.0+build-3", + annotated: true, + commitTime: now.Add(1 * time.Hour), + tagTime: now.Add(1 * time.Hour), // This should be ignored during TS comparisons + }, + { + tag: "0.2.0", + annotated: true, + commitTime: now, + tagTime: now, + }, + } + + // Clone the repo locally. + cloneDir, err := os.MkdirTemp("", "test-clone") + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(cloneDir) + repo, err := extgogit.PlainClone(cloneDir, false, &extgogit.CloneOptions{ + URL: repoURL, + }) + g.Expect(err).ToNot(HaveOccurred()) + + // Create commits and tags. + // Keep a record of all the tags and commit refs. + refs := make(map[string]string, len(tags)) + for _, tt := range tags { + ref, err := commitFile(repo, "tag", tt.tag, tt.commitTime) + g.Expect(err).ToNot(HaveOccurred()) + _, err = tag(repo, ref, tt.annotated, tt.tag, tt.tagTime) + g.Expect(err).ToNot(HaveOccurred()) + refs[tt.tag] = ref.String() + } + + // Push everything. + err = repo.Push(&extgogit.PushOptions{ + RefSpecs: []config.RefSpec{"refs/*:refs/*"}, + }) + g.Expect(err).ToNot(HaveOccurred()) + + // Test cases. + type testCase struct { + name string + constraint string + expectErr error + expectTag string + } + tests := []testCase{ + { + name: "Orders by SemVer", + constraint: ">0.1.0", + expectTag: "0.2.0", + }, + { + name: "Orders by SemVer and timestamp", + constraint: "<0.2.0", + expectTag: "v0.1.0+build-3", + }, + { + name: "Errors without match", + constraint: ">=1.0.0", + expectErr: errors.New("no match found for semver: >=1.0.0"), + }, + } + testFunc := func(tt testCase, impl git.Implementation) func(t *testing.T) { + return func(t *testing.T) { + g := NewWithT(t) + + // Get the checkout strategy. + checkoutOpts := git.CheckoutOptions{ + SemVer: tt.constraint, + } + checkoutStrategy, err := CheckoutStrategyForImplementation(context.TODO(), impl, checkoutOpts) + g.Expect(err).ToNot(HaveOccurred()) + + // Checkout and verify. + tmpDir, err := os.MkdirTemp("", "test-checkout") + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(tmpDir) + + cc, err := checkoutStrategy.Checkout(context.TODO(), tmpDir, repoURL, authOpts) + if tt.expectErr != nil { + g.Expect(err).To(Equal(tt.expectErr)) + g.Expect(cc).To(BeNil()) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cc.String()).To(Equal(tt.expectTag + "/" + refs[tt.expectTag])) + g.Expect(filepath.Join(tmpDir, "tag")).To(BeARegularFile()) + g.Expect(os.ReadFile(filepath.Join(tmpDir, "tag"))).To(BeEquivalentTo(tt.expectTag)) + } + } + + // Run the test cases against the git implementations. + for _, gitImpl := range gitImpls { + for _, tt := range tests { + t.Run(string(gitImpl)+"_"+tt.name, testFunc(tt, gitImpl)) + } + } +} + +func commitFile(repo *extgogit.Repository, path, content string, time time.Time) (plumbing.Hash, error) { + wt, err := repo.Worktree() + if err != nil { + return plumbing.Hash{}, err + } + f, err := wt.Filesystem.Create(path) + if err != nil { + return plumbing.Hash{}, err + } + if _, err := f.Write([]byte(content)); err != nil { + if ferr := f.Close(); ferr != nil { + return plumbing.Hash{}, ferr + } + return plumbing.Hash{}, err + } + if err := f.Close(); err != nil { + return plumbing.Hash{}, err + } + if _, err := wt.Add(path); err != nil { + return plumbing.Hash{}, err + } + return wt.Commit("Adding: "+path, &extgogit.CommitOptions{ + Author: mockSignature(time), + Committer: mockSignature(time), + }) +} + +func tag(repo *extgogit.Repository, commit plumbing.Hash, annotated bool, tag string, time time.Time) (*plumbing.Reference, error) { + var opts *extgogit.CreateTagOptions + if annotated { + opts = &extgogit.CreateTagOptions{ + Tagger: mockSignature(time), + Message: "Annotated tag for: " + tag, + } + } + return repo.CreateTag(tag, commit, opts) +} + +func mockSignature(time time.Time) *object.Signature { + return &object.Signature{ + Name: "Jane Doe", + Email: "jane@example.com", + When: time, + } +} diff --git a/pkg/git/strategy/testdata/certs/Makefile b/pkg/git/strategy/testdata/certs/Makefile new file mode 100644 index 000000000..5ec8f26c6 --- /dev/null +++ b/pkg/git/strategy/testdata/certs/Makefile @@ -0,0 +1,30 @@ +# 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. + +all: server-key.pem + +ca-key.pem: ca-csr.json + cfssl gencert -initca ca-csr.json | cfssljson -bare ca – +ca.pem: ca-key.pem +ca.csr: ca-key.pem + +server-key.pem: server-csr.json ca-config.json ca-key.pem + cfssl gencert \ + -ca=ca.pem \ + -ca-key=ca-key.pem \ + -config=ca-config.json \ + -profile=web-servers \ + server-csr.json | cfssljson -bare server +sever.pem: server-key.pem +server.csr: server-key.pem diff --git a/pkg/git/strategy/testdata/certs/ca-config.json b/pkg/git/strategy/testdata/certs/ca-config.json new file mode 100644 index 000000000..91c0644c6 --- /dev/null +++ b/pkg/git/strategy/testdata/certs/ca-config.json @@ -0,0 +1,18 @@ +{ + "signing": { + "default": { + "expiry": "87600h" + }, + "profiles": { + "web-servers": { + "usages": [ + "signing", + "key encipherment", + "server auth", + "client auth" + ], + "expiry": "87600h" + } + } + } +} diff --git a/pkg/git/strategy/testdata/certs/ca-csr.json b/pkg/git/strategy/testdata/certs/ca-csr.json new file mode 100644 index 000000000..941277bb1 --- /dev/null +++ b/pkg/git/strategy/testdata/certs/ca-csr.json @@ -0,0 +1,9 @@ +{ + "CN": "example.com CA", + "hosts": [ + "127.0.0.1", + "localhost", + "example.com", + "www.example.com" + ] +} diff --git a/pkg/git/strategy/testdata/certs/ca-key.pem b/pkg/git/strategy/testdata/certs/ca-key.pem new file mode 100644 index 000000000..b69de5ab5 --- /dev/null +++ b/pkg/git/strategy/testdata/certs/ca-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIOH/u9dMcpVcZ0+X9Fc78dCTj8SHuXawhLjhu/ej64WToAoGCCqGSM49 +AwEHoUQDQgAEruH/kPxtX3cyYR2G7TYmxLq6AHyzo/NGXc9XjGzdJutE2SQzn37H +dvSJbH+Lvqo9ik0uiJVRVdCYD1j7gNszGA== +-----END EC PRIVATE KEY----- diff --git a/pkg/git/strategy/testdata/certs/ca.csr b/pkg/git/strategy/testdata/certs/ca.csr new file mode 100644 index 000000000..baa8aeb26 --- /dev/null +++ b/pkg/git/strategy/testdata/certs/ca.csr @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBIDCBxgIBADAZMRcwFQYDVQQDEw5leGFtcGxlLmNvbSBDQTBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABK7h/5D8bV93MmEdhu02JsS6ugB8s6PzRl3PV4xs3Sbr +RNkkM59+x3b0iWx/i76qPYpNLoiVUVXQmA9Y+4DbMxigSzBJBgkqhkiG9w0BCQ4x +PDA6MDgGA1UdEQQxMC+CCWxvY2FsaG9zdIILZXhhbXBsZS5jb22CD3d3dy5leGFt +cGxlLmNvbYcEfwAAATAKBggqhkjOPQQDAgNJADBGAiEAkw85nyLhJssyCYsaFvRU +EErhu66xHPJug/nG50uV5OoCIQCUorrflOSxfChPeCe4xfwcPv7FpcCYbKVYtGzz +b34Wow== +-----END CERTIFICATE REQUEST----- diff --git a/pkg/git/strategy/testdata/certs/ca.pem b/pkg/git/strategy/testdata/certs/ca.pem new file mode 100644 index 000000000..080bd24e6 --- /dev/null +++ b/pkg/git/strategy/testdata/certs/ca.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBhzCCAS2gAwIBAgIUdsAtiX3gN0uk7ddxASWYE/tdv0wwCgYIKoZIzj0EAwIw +GTEXMBUGA1UEAxMOZXhhbXBsZS5jb20gQ0EwHhcNMjAwNDE3MDgxODAwWhcNMjUw +NDE2MDgxODAwWjAZMRcwFQYDVQQDEw5leGFtcGxlLmNvbSBDQTBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABK7h/5D8bV93MmEdhu02JsS6ugB8s6PzRl3PV4xs3Sbr +RNkkM59+x3b0iWx/i76qPYpNLoiVUVXQmA9Y+4DbMxijUzBRMA4GA1UdDwEB/wQE +AwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGyUiU1QEZiMAqjsnIYTwZ +4yp5wzAPBgNVHREECDAGhwR/AAABMAoGCCqGSM49BAMCA0gAMEUCIQDzdtvKdE8O +1+WRTZ9MuSiFYcrEz7Zne7VXouDEKqKEigIgM4WlbDeuNCKbqhqj+xZV0pa3rweb +OD8EjjCMY69RMO0= +-----END CERTIFICATE----- diff --git a/pkg/git/strategy/testdata/certs/server-csr.json b/pkg/git/strategy/testdata/certs/server-csr.json new file mode 100644 index 000000000..0baf11601 --- /dev/null +++ b/pkg/git/strategy/testdata/certs/server-csr.json @@ -0,0 +1,9 @@ +{ + "CN": "example.com", + "hosts": [ + "127.0.0.1", + "localhost", + "example.com", + "www.example.com" + ] +} diff --git a/pkg/git/strategy/testdata/certs/server-key.pem b/pkg/git/strategy/testdata/certs/server-key.pem new file mode 100644 index 000000000..5054ff39f --- /dev/null +++ b/pkg/git/strategy/testdata/certs/server-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKQbEXV6nljOHMmPrWVWQ+JrAE5wsbE9iMhfY7wlJgXOoAoGCCqGSM49 +AwEHoUQDQgAE+53oBGlrvVUTelSGYji8GNHVhVg8jOs1PeeLuXCIZjQmctHLFEq3 +fE+mGxCL93MtpYzlwIWBf0m7pEGQre6bzg== +-----END EC PRIVATE KEY----- diff --git a/pkg/git/strategy/testdata/certs/server.csr b/pkg/git/strategy/testdata/certs/server.csr new file mode 100644 index 000000000..5caf7b39c --- /dev/null +++ b/pkg/git/strategy/testdata/certs/server.csr @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBHDCBwwIBADAWMRQwEgYDVQQDEwtleGFtcGxlLmNvbTBZMBMGByqGSM49AgEG +CCqGSM49AwEHA0IABPud6ARpa71VE3pUhmI4vBjR1YVYPIzrNT3ni7lwiGY0JnLR +yxRKt3xPphsQi/dzLaWM5cCFgX9Ju6RBkK3um86gSzBJBgkqhkiG9w0BCQ4xPDA6 +MDgGA1UdEQQxMC+CCWxvY2FsaG9zdIILZXhhbXBsZS5jb22CD3d3dy5leGFtcGxl +LmNvbYcEfwAAATAKBggqhkjOPQQDAgNIADBFAiB5A6wvQ5x6g/zhiyn+wLzXsOaB +Gb/F25p/zTHHQqZbkwIhAPUgWzy/2bs6eZEi97bSlaRdmrqHwqT842t5sEwGyXNV +-----END CERTIFICATE REQUEST----- diff --git a/pkg/git/strategy/testdata/certs/server.pem b/pkg/git/strategy/testdata/certs/server.pem new file mode 100644 index 000000000..11c655a0b --- /dev/null +++ b/pkg/git/strategy/testdata/certs/server.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB7TCCAZKgAwIBAgIUB+17B8PU05wVTzRHLeG+S+ybZK4wCgYIKoZIzj0EAwIw +GTEXMBUGA1UEAxMOZXhhbXBsZS5jb20gQ0EwHhcNMjAwNDE3MDgxODAwWhcNMzAw +NDE1MDgxODAwWjAWMRQwEgYDVQQDEwtleGFtcGxlLmNvbTBZMBMGByqGSM49AgEG +CCqGSM49AwEHA0IABPud6ARpa71VE3pUhmI4vBjR1YVYPIzrNT3ni7lwiGY0JnLR +yxRKt3xPphsQi/dzLaWM5cCFgX9Ju6RBkK3um86jgbowgbcwDgYDVR0PAQH/BAQD +AgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAA +MB0GA1UdDgQWBBTM8HS5EIlVMBYv/300jN8PEArUgDAfBgNVHSMEGDAWgBQGyUiU +1QEZiMAqjsnIYTwZ4yp5wzA4BgNVHREEMTAvgglsb2NhbGhvc3SCC2V4YW1wbGUu +Y29tgg93d3cuZXhhbXBsZS5jb22HBH8AAAEwCgYIKoZIzj0EAwIDSQAwRgIhAOgB +5W82FEgiTTOmsNRekkK5jUPbj4D4eHtb2/BI7ph4AiEA2AxHASIFBdv5b7Qf5prb +bdNmUCzAvVuCAKuMjg2OPrE= +-----END CERTIFICATE----- diff --git a/pkg/git/strategy/testdata/repo1/foo.txt b/pkg/git/strategy/testdata/repo1/foo.txt new file mode 100644 index 000000000..16b14f5da --- /dev/null +++ b/pkg/git/strategy/testdata/repo1/foo.txt @@ -0,0 +1 @@ +test file