/* 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 ( "context" "errors" "fmt" "net/url" "os" "path/filepath" "strings" "testing" "time" "github.com/fluxcd/gitkit" "github.com/fluxcd/pkg/gittestserver" "github.com/fluxcd/pkg/ssh" "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" cryptossh "golang.org/x/crypto/ssh" corev1 "k8s.io/api/core/v1" ) const testRepositoryPath = "../testdata/git/repo" func TestCheckoutBranch_Checkout(t *testing.T) { repo, path, err := initRepo(t) if err != nil { t.Fatal(err) } 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 filesCreated map[string]string expectedCommit string expectedErr string }{ { name: "Default branch", branch: "master", filesCreated: map[string]string{"branch": "init"}, expectedCommit: firstCommit.String(), }, { name: "Other branch", branch: "test", filesCreated: map[string]string{"branch": "second"}, 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 := t.TempDir() cc, err := branch.Checkout(context.TODO(), tmpDir, path, nil) if tt.expectedErr != "" { g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(ContainSubstring(tt.expectedErr)) g.Expect(cc).To(BeNil()) return } g.Expect(err).ToNot(HaveOccurred()) g.Expect(cc.String()).To(Equal(tt.branch + "/" + tt.expectedCommit)) for k, v := range tt.filesCreated { g.Expect(filepath.Join(tmpDir, k)).To(BeARegularFile()) g.Expect(os.ReadFile(filepath.Join(tmpDir, k))).To(BeEquivalentTo(v)) } }) } } 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(t) if err != nil { t.Fatal(err) } 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 := t.TempDir() 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).ToNot(HaveOccurred()) 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(t) if err != nil { t.Fatal(err) } 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 := t.TempDir() 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) { 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(t) if err != nil { t.Fatal(err) } 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 := t.TempDir() 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)) }) } } // Test_KeyTypes assures support for the different types of keys // for SSH Authentication supported by Flux. func Test_KeyTypes(t *testing.T) { tests := []struct { name string keyType ssh.KeyPairType authorized bool wantErr string }{ {name: "RSA 4096", keyType: ssh.RSA_4096, authorized: true}, {name: "ECDSA P256", keyType: ssh.ECDSA_P256, authorized: true}, {name: "ECDSA P384", keyType: ssh.ECDSA_P384, authorized: true}, {name: "ECDSA P521", keyType: ssh.ECDSA_P521, authorized: true}, {name: "ED25519", keyType: ssh.ED25519, authorized: true}, {name: "unauthorized key", keyType: ssh.RSA_4096, wantErr: "unable to authenticate, attempted methods [none publickey], no supported methods remain"}, } serverRootDir := t.TempDir() server := gittestserver.NewGitServer(serverRootDir) // Auth needs to be called, for authentication to be enabled. server.Auth("", "") var authorizedPublicKey string server.PublicKeyLookupFunc(func(content string) (*gitkit.PublicKey, error) { authedKey := strings.TrimSuffix(string(authorizedPublicKey), "\n") if authedKey == content { return &gitkit.PublicKey{Content: content}, nil } return nil, fmt.Errorf("pubkey provided '%s' does not match %s", content, authedKey) }) g := NewWithT(t) timeout := 5 * time.Second server.KeyDir(filepath.Join(server.Root(), "keys")) g.Expect(server.ListenSSH()).To(Succeed()) go func() { server.StartSSH() }() defer server.StopSSH() repoPath := "test.git" err := server.InitRepo(testRepositoryPath, git.DefaultBranch, repoPath) g.Expect(err).NotTo(HaveOccurred()) sshURL := server.SSHAddress() repoURL := sshURL + "/" + repoPath // Fetch host key. u, err := url.Parse(sshURL) g.Expect(err).NotTo(HaveOccurred()) g.Expect(u.Host).ToNot(BeEmpty()) knownHosts, err := ssh.ScanHostKey(u.Host, timeout, git.HostKeyAlgos) g.Expect(err).ToNot(HaveOccurred()) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) // Generate ssh keys based on key type. kp, err := ssh.GenerateKeyPair(tt.keyType) g.Expect(err).ToNot(HaveOccurred()) // Update authorized key to ensure only the new key is valid on the server. if tt.authorized { authorizedPublicKey = string(kp.PublicKey) } secret := corev1.Secret{ Data: map[string][]byte{ "identity": kp.PrivateKey, "known_hosts": knownHosts, }, } authOpts, err := git.AuthOptionsFromSecret(repoURL, &secret) g.Expect(err).ToNot(HaveOccurred()) // Prepare for checkout. branchCheckoutStrat := &CheckoutBranch{Branch: git.DefaultBranch} tmpDir := t.TempDir() ctx, cancel := context.WithTimeout(context.TODO(), timeout) defer cancel() // Checkout the repo. commit, err := branchCheckoutStrat.Checkout(ctx, tmpDir, repoURL, authOpts) if tt.wantErr == "" { g.Expect(err).ToNot(HaveOccurred()) g.Expect(commit).ToNot(BeNil()) // Confirm checkout actually happened. d, err := os.ReadDir(tmpDir) g.Expect(err).ToNot(HaveOccurred()) g.Expect(d).To(HaveLen(2)) // .git and foo.txt } else { g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).Should(ContainSubstring(tt.wantErr)) } }) } } // Test_KeyExchangeAlgos assures support for the different // types of SSH key exchange algorithms supported by Flux. func Test_KeyExchangeAlgos(t *testing.T) { tests := []struct { name string ClientKex []string ServerKex []string wantErr string }{ { name: "support for kex: diffie-hellman-group14-sha1", ClientKex: []string{"diffie-hellman-group14-sha1"}, ServerKex: []string{"diffie-hellman-group14-sha1"}, }, { name: "support for kex: diffie-hellman-group14-sha256", ClientKex: []string{"diffie-hellman-group14-sha256"}, ServerKex: []string{"diffie-hellman-group14-sha256"}, }, { name: "support for kex: curve25519-sha256", ClientKex: []string{"curve25519-sha256"}, ServerKex: []string{"curve25519-sha256"}, }, { name: "support for kex: ecdh-sha2-nistp256", ClientKex: []string{"ecdh-sha2-nistp256"}, ServerKex: []string{"ecdh-sha2-nistp256"}, }, { name: "support for kex: ecdh-sha2-nistp384", ClientKex: []string{"ecdh-sha2-nistp384"}, ServerKex: []string{"ecdh-sha2-nistp384"}, }, { name: "support for kex: ecdh-sha2-nistp521", ClientKex: []string{"ecdh-sha2-nistp521"}, ServerKex: []string{"ecdh-sha2-nistp521"}, }, { name: "support for kex: curve25519-sha256@libssh.org", ClientKex: []string{"curve25519-sha256@libssh.org"}, ServerKex: []string{"curve25519-sha256@libssh.org"}, }, { name: "non-matching kex", ClientKex: []string{"ecdh-sha2-nistp521"}, ServerKex: []string{"curve25519-sha256@libssh.org"}, wantErr: "ssh: no common algorithm for key exchange; client offered: [ecdh-sha2-nistp521 ext-info-c], server offered: [curve25519-sha256@libssh.org]", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) timeout := 5 * time.Second serverRootDir := t.TempDir() server := gittestserver.NewGitServer(serverRootDir).WithSSHConfig(&cryptossh.ServerConfig{ Config: cryptossh.Config{ KeyExchanges: tt.ServerKex, }, }) // Set what Client Key Exchange Algos to send git.KexAlgos = tt.ClientKex server.KeyDir(filepath.Join(server.Root(), "keys")) g.Expect(server.ListenSSH()).To(Succeed()) go func() { server.StartSSH() }() defer server.StopSSH() repoPath := "test.git" err := server.InitRepo(testRepositoryPath, git.DefaultBranch, repoPath) g.Expect(err).NotTo(HaveOccurred()) sshURL := server.SSHAddress() repoURL := sshURL + "/" + repoPath // Fetch host key. u, err := url.Parse(sshURL) g.Expect(err).NotTo(HaveOccurred()) g.Expect(u.Host).ToNot(BeEmpty()) knownHosts, err := ssh.ScanHostKey(u.Host, timeout, git.HostKeyAlgos) g.Expect(err).ToNot(HaveOccurred()) // No authentication is required for this test, but it is // used here to make the Checkout logic happy. kp, err := ssh.GenerateKeyPair(ssh.ED25519) g.Expect(err).ToNot(HaveOccurred()) secret := corev1.Secret{ Data: map[string][]byte{ "identity": kp.PrivateKey, "known_hosts": knownHosts, }, } authOpts, err := git.AuthOptionsFromSecret(repoURL, &secret) g.Expect(err).ToNot(HaveOccurred()) // Prepare for checkout. branchCheckoutStrat := &CheckoutBranch{Branch: git.DefaultBranch} tmpDir := t.TempDir() ctx, cancel := context.WithTimeout(context.TODO(), timeout) defer cancel() // Checkout the repo. _, err = branchCheckoutStrat.Checkout(ctx, tmpDir, repoURL, authOpts) if tt.wantErr != "" { g.Expect(err).Error().Should(HaveOccurred()) g.Expect(err.Error()).Should(ContainSubstring(tt.wantErr)) } else { g.Expect(err).Error().ShouldNot(HaveOccurred()) } }) } } // TestHostKeyAlgos assures support for the different // types of SSH Host Key algorithms supported by Flux. func TestHostKeyAlgos(t *testing.T) { tests := []struct { name string keyType ssh.KeyPairType ClientHostKeyAlgos []string }{ { name: "support for hostkey: ssh-rsa", keyType: ssh.RSA_4096, ClientHostKeyAlgos: []string{"ssh-rsa"}, }, { name: "support for hostkey: rsa-sha2-256", keyType: ssh.RSA_4096, ClientHostKeyAlgos: []string{"rsa-sha2-256"}, }, { name: "support for hostkey: rsa-sha2-512", keyType: ssh.RSA_4096, ClientHostKeyAlgos: []string{"rsa-sha2-512"}, }, { name: "support for hostkey: ecdsa-sha2-nistp256", keyType: ssh.ECDSA_P256, ClientHostKeyAlgos: []string{"ecdsa-sha2-nistp256"}, }, { name: "support for hostkey: ecdsa-sha2-nistp384", keyType: ssh.ECDSA_P384, ClientHostKeyAlgos: []string{"ecdsa-sha2-nistp384"}, }, { name: "support for hostkey: ecdsa-sha2-nistp521", keyType: ssh.ECDSA_P521, ClientHostKeyAlgos: []string{"ecdsa-sha2-nistp521"}, }, { name: "support for hostkey: ssh-ed25519", keyType: ssh.ED25519, ClientHostKeyAlgos: []string{"ssh-ed25519"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) timeout := 5 * time.Second sshConfig := &cryptossh.ServerConfig{} // Generate new keypair for the server to use for HostKeys. hkp, err := ssh.GenerateKeyPair(tt.keyType) g.Expect(err).NotTo(HaveOccurred()) p, err := cryptossh.ParseRawPrivateKey(hkp.PrivateKey) g.Expect(err).NotTo(HaveOccurred()) // Add key to server. signer, err := cryptossh.NewSignerFromKey(p) g.Expect(err).NotTo(HaveOccurred()) sshConfig.AddHostKey(signer) serverRootDir := t.TempDir() server := gittestserver.NewGitServer(serverRootDir).WithSSHConfig(sshConfig) // Set what HostKey Algos will be accepted from a client perspective. git.HostKeyAlgos = tt.ClientHostKeyAlgos keyDir := filepath.Join(server.Root(), "keys") server.KeyDir(keyDir) g.Expect(server.ListenSSH()).To(Succeed()) go func() { server.StartSSH() }() defer server.StopSSH() repoPath := "test.git" err = server.InitRepo(testRepositoryPath, git.DefaultBranch, repoPath) g.Expect(err).NotTo(HaveOccurred()) sshURL := server.SSHAddress() repoURL := sshURL + "/" + repoPath // Fetch host key. u, err := url.Parse(sshURL) g.Expect(err).NotTo(HaveOccurred()) g.Expect(u.Host).ToNot(BeEmpty()) knownHosts, err := ssh.ScanHostKey(u.Host, timeout, git.HostKeyAlgos) g.Expect(err).ToNot(HaveOccurred()) // No authentication is required for this test, but it is // used here to make the Checkout logic happy. kp, err := ssh.GenerateKeyPair(ssh.ED25519) g.Expect(err).ToNot(HaveOccurred()) secret := corev1.Secret{ Data: map[string][]byte{ "identity": kp.PrivateKey, "known_hosts": knownHosts, }, } authOpts, err := git.AuthOptionsFromSecret(repoURL, &secret) g.Expect(err).ToNot(HaveOccurred()) // Prepare for checkout. branchCheckoutStrat := &CheckoutBranch{Branch: git.DefaultBranch} tmpDir := t.TempDir() ctx, cancel := context.WithTimeout(context.TODO(), timeout) defer cancel() // Checkout the repo. _, err = branchCheckoutStrat.Checkout(ctx, tmpDir, repoURL, authOpts) g.Expect(err).Error().ShouldNot(HaveOccurred()) }) } } func initRepo(t *testing.T) (*extgogit.Repository, string, error) { tmpDir := t.TempDir() sto := filesystem.NewStorage(osfs.New(tmpDir), cache.NewObjectLRUDefault()) repo, err := extgogit.Init(sto, memfs.New()) if err != nil { return nil, "", err } return repo, tmpDir, err } 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 } return wt.Checkout(&extgogit.CheckoutOptions{ Hash: h.Hash(), Branch: plumbing.ReferenceName("refs/heads/" + branch), Create: true, }) } 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 { 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) } func mockSignature(time time.Time) *object.Signature { return &object.Signature{ Name: "Jane Doe", Email: "jane@example.com", When: time, } }