diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index ce1c995bb7..77d8abaa7b 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -81,6 +81,9 @@ jobs: run: | git diff --exit-code --stat -- . ':(exclude)web/package.json' ':(exclude)web/package-lock.json' ':(exclude)go.mod' ':(exclude)go.sum' ':(exclude)docs' ':(exclude)pro_impl' + - name: Install subversion + run: sudo apt-get update && sudo apt-get install -y subversion + - name: Run tests run: task test diff --git a/db/Repository.go b/db/Repository.go index 173e6ad849..f1b02cc9b7 100644 --- a/db/Repository.go +++ b/db/Repository.go @@ -11,6 +11,7 @@ import ( "github.com/semaphoreui/semaphore/pkg/common_errors" "github.com/semaphoreui/semaphore/pkg/git" + "github.com/semaphoreui/semaphore/pkg/svn" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" @@ -24,6 +25,11 @@ const ( RepositoryHTTP RepositoryType = "https" RepositoryFile RepositoryType = "file" RepositoryLocal RepositoryType = "local" + + // RepositorySVN and RepositorySVNSSH are Subversion repositories, served by + // svnserve directly or tunnelled through ssh. + RepositorySVN RepositoryType = "svn" + RepositorySVNSSH RepositoryType = "svn+ssh" ) // Repository is the model for code stored in a git repository @@ -226,6 +232,10 @@ func (r Repository) GetType() RepositoryType { return RepositoryLocal } + if svn.IsSvnSSHURL(r.GitURL) { + return RepositorySVNSSH + } + re := regexp.MustCompile(`^(\w+)://`) m := re.FindStringSubmatch(r.GitURL) if m == nil { @@ -269,3 +279,23 @@ func (r Repository) Validate() error { return nil } + +// ValidateCommitRef rejects a commit a task can not be checked out at: a hex +// object name for git, a revision number for Subversion. +func (r Repository) ValidateCommitRef(ref string, objectName string) error { + if r.IsSubversion() { + return svn.ValidateRevision(ref, objectName) + } + return git.ValidateCommitHash(ref, objectName) +} + +// IsSubversion reports whether the repository is served by Subversion rather +// than git. +func (r Repository) IsSubversion() bool { + switch r.GetType() { + case RepositorySVN, RepositorySVNSSH: + return true + default: + return false + } +} diff --git a/db/Repository_test.go b/db/Repository_test.go index e5e04b0544..19f950ca05 100644 --- a/db/Repository_test.go +++ b/db/Repository_test.go @@ -37,6 +37,79 @@ func TestRepository_GetType_CaseInsensitiveScheme(t *testing.T) { } } +func TestRepository_GetType_Subversion(t *testing.T) { + tests := []struct { + name string + gitURL string + expected RepositoryType + }{ + {"svn", "svn://svn.example.com/repo/trunk", RepositorySVN}, + {"uppercase SVN", "SVN://svn.example.com/repo/trunk", RepositorySVN}, + {"svn+ssh", "svn+ssh://user@svn.example.com/repo/trunk", RepositorySVNSSH}, + {"uppercase svn+ssh", "SVN+SSH://svn.example.com/repo/trunk", RepositorySVNSSH}, + {"git+ssh is not subversion", "git+ssh://git@example.com/repo.git", RepositorySSH}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := Repository{GitURL: tt.gitURL} + assert.Equal(t, tt.expected, repo.GetType()) + assert.Equal(t, tt.expected != RepositorySSH, repo.IsSubversion()) + }) + } +} + +func TestRepository_Validate_SubversionBranchPath(t *testing.T) { + tests := []struct { + name string + branch string + wantErr bool + }{ + {"trunk", "trunk", false}, + {"branch", "branches/release", false}, + {"tag", "tags/v1.0", false}, + {"empty", "", true}, + {"parent directory", "../other", true}, + {"option injection", "--config-dir=/tmp", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Repository{Name: "r", GitURL: "svn://svn.example.com/repo", GitBranch: tt.branch}.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestRepository_ValidateCommitRef(t *testing.T) { + git := Repository{GitURL: "https://example.com/repo.git"} + svn := Repository{GitURL: "svn://svn.example.com/repo"} + + tests := []struct { + name string + repo Repository + ref string + wantErr bool + }{ + {"git hash", git, "a1b2c3d", false}, + {"git rejects a revision", git, "42", true}, + {"svn revision", svn, "42", false}, + {"svn rejects a git hash", svn, "a1b2c3d", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.repo.ValidateCommitRef(tt.ref, "task") + if tt.wantErr { + assert.EqualError(t, err, "task commit hash is invalid") + } else { + assert.NoError(t, err) + } + }) + } +} + func TestRepository_GetType_WindowsLocalPath(t *testing.T) { assert.Equal(t, RepositoryLocal, Repository{GitURL: `D:\repo`}.GetType()) assert.Equal(t, RepositoryLocal, Repository{GitURL: `D:/repo`}.GetType()) diff --git a/db/Task.go b/db/Task.go index 4adc336f9f..91e5452af0 100644 --- a/db/Task.go +++ b/db/Task.go @@ -195,7 +195,9 @@ func (task *Task) GetWorkflowUrl() *string { return &workflowUrl } -func (task *Task) ValidateNewTask(template Template) error { +// ValidateNewTask checks a task before it is created. The repository is the +// template's: the commit a task may be checked out at depends on its type. +func (task *Task) ValidateNewTask(template Template, repository Repository) error { if task.GitBranch != nil { if err := git.ValidateGitBranch(*task.GitBranch, "task"); err != nil { return err @@ -203,7 +205,7 @@ func (task *Task) ValidateNewTask(template Template) error { } if task.CommitHash != nil { - if err := git.ValidateCommitHash(*task.CommitHash, "task"); err != nil { + if err := repository.ValidateCommitRef(*task.CommitHash, "task"); err != nil { return err } } diff --git a/db/playbook_path_test.go b/db/playbook_path_test.go index ff52117eaf..9bb730959a 100644 --- a/db/playbook_path_test.go +++ b/db/playbook_path_test.go @@ -81,7 +81,7 @@ func TestTask_ValidateNewTask_AbsolutePlaybook_ReturnsError(t *testing.T) { Playbook: "/usr/bin/evil.sh", } - err := task.ValidateNewTask(Template{}) + err := task.ValidateNewTask(Template{}, Repository{}) assert.EqualError(t, err, "task playbook must be a relative path inside the repository") } @@ -91,7 +91,7 @@ func TestTask_ValidateNewTask_RelativePlaybook_ReturnsNoError(t *testing.T) { Playbook: "site.yml", } - err := task.ValidateNewTask(Template{}) + err := task.ValidateNewTask(Template{}, Repository{}) assert.NoError(t, err) } diff --git a/db_lib/GitClientFactory.go b/db_lib/GitClientFactory.go index e433ac1781..56d45592e7 100644 --- a/db_lib/GitClientFactory.go +++ b/db_lib/GitClientFactory.go @@ -2,7 +2,16 @@ package db_lib import "github.com/semaphoreui/semaphore/util" +// CreateDefaultGitClient returns the configured git client, which hands +// Subversion repositories over to the svn client. func CreateDefaultGitClient(keyInstaller AccessKeyInstaller) GitClient { + return repositoryTypeClient{ + git: createConfiguredGitClient(keyInstaller), + svn: CreateSvnClient(keyInstaller), + } +} + +func createConfiguredGitClient(keyInstaller AccessKeyInstaller) GitClient { switch util.Config.GitClientId { case util.GoGitClientId: return CreateGoGitClient(keyInstaller) diff --git a/db_lib/SvnClient.go b/db_lib/SvnClient.go new file mode 100644 index 0000000000..e63079ea3c --- /dev/null +++ b/db_lib/SvnClient.go @@ -0,0 +1,417 @@ +package db_lib + +import ( + "encoding/xml" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/pkg/git" + "github.com/semaphoreui/semaphore/pkg/ssh" + "github.com/semaphoreui/semaphore/pkg/svn" + "github.com/semaphoreui/semaphore/util" + + log "github.com/sirupsen/logrus" +) + +// SvnClient serves Subversion repositories through the svn binary. +// +// The repository URL is the repository root, and the branch is a path under it +// (trunk, branches/x, tags/y), so a template or a task selects a branch the way +// it does for git. The last revision which changed that path stands in for the +// commit hash. +type SvnClient struct { + keyInstaller AccessKeyInstaller +} + +func CreateSvnClient(keyInstaller AccessKeyInstaller) GitClient { + return SvnClient{ + keyInstaller: keyInstaller, + } +} + +// svnBranchURL returns the URL of the branch path, without a peg revision. +// +// The branch is validated as a git branch name when the repository, template +// or task is saved, which also keeps it from climbing out of the root ("..") +// or carrying options; it is checked again here because the playbook browser +// takes it from the query string. +func svnBranchURL(r GitRepository) (string, error) { + if err := git.ValidateGitBranch(r.Repository.GitBranch, "repository"); err != nil { + return "", err + } + + root := strings.TrimRight(r.Repository.GetGitURL(false), "/") + if r.Repository.GitBranch == "" { + return root, nil + } + return root + "/" + r.Repository.GitBranch, nil +} + +// svnPeg ends a URL with an empty peg revision. svn reads the text after the +// last "@" of a URL as its peg revision, so a path containing "@" would +// otherwise be cut there. +func svnPeg(u string) string { + return u + "@" +} + +// svnGlobalArgs are given to every svn command. Prompting would hang the task, +// and a cached password would outlive the access key it came from. +func svnGlobalArgs(r GitRepository) []string { + args := []string{"--non-interactive", "--no-auth-cache"} + + if r.Repository.SSHKey.Type == db.AccessKeyLoginPassword { + if login := r.Repository.SSHKey.LoginPassword.Login; login != "" { + args = append(args, "--username", login) + } + if r.Repository.SSHKey.LoginPassword.Password != "" { + // From stdin, not --password: the arguments of a process are + // visible to every user of the host. + args = append(args, "--password-from-stdin") + } + } + + return args +} + +func (c SvnClient) makeCmd( + r GitRepository, + targetDir GitRepositoryDirType, + installation ssh.AccessKeyInstallation, + args ...string, +) *exec.Cmd { + cmd := exec.Command("svn") //nolint:gosec + + cmd.Env = getEnvironmentVars() + + // svn+ssh:// runs the command in SVN_SSH, which gets the agent, the host + // key checking and the project's host configs git gets. + for _, v := range installation.GetGitEnvWithHostConfigs(r.HostConfigs) { + if sshCmd, ok := strings.CutPrefix(v, "GIT_SSH_COMMAND="); ok { + cmd.Env = append(cmd.Env, "SVN_SSH="+sshCmd) + } else if strings.HasPrefix(v, "SSH_AUTH_SOCK=") { + cmd.Env = append(cmd.Env, v) + } + } + + // Same rule as git: never an empty HOME, which would hide the svn + // configuration of the user Semaphore runs as. + if !hasNonEmptyEnvVar(cmd.Env, "HOME") { + if homeDir := getHomeDir(r.Repository, r.TemplateID); homeDir != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", homeDir)) + } else if h := os.Getenv("HOME"); h != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", h)) + } + } + appendPlatformEnv(&cmd.Env) + + // The environment carries no locale of its own. Without one, svn writes + // translated messages in a legacy encoding the task log shows as garbage, + // and on Linux it refuses to check out a file whose name is not ASCII + // ("Can't convert string from 'UTF-8' to native encoding"). A locale set + // by the administrator wins. + if !hasNonEmptyEnvVar(cmd.Env, "LC_ALL") && !hasNonEmptyEnvVar(cmd.Env, "LC_CTYPE") && + !hasNonEmptyEnvVar(cmd.Env, "LANG") { + cmd.Env = append(cmd.Env, "LC_CTYPE=C.UTF-8") + } + + switch targetDir { + case GitRepositoryTmpPath: + cmd.Dir = util.Config.GetProjectTmpDir(r.Repository.ProjectID) + if err := os.MkdirAll(cmd.Dir, 0755); err != nil { + log.WithError(err).WithFields(log.Fields{ + "context": "svn", + }).Error("failed to create project temp directory") + } + case GitRepositoryFullPath: + cmd.Dir = r.GetFullPath() + default: + panic("unknown Repository directory type") + } + + cmd.Args = append(cmd.Args, svnGlobalArgs(r)...) + cmd.Args = append(cmd.Args, args...) + + if r.Repository.SSHKey.Type == db.AccessKeyLoginPassword && r.Repository.SSHKey.LoginPassword.Password != "" { + cmd.Stdin = strings.NewReader(r.Repository.SSHKey.LoginPassword.Password) + } + + cmd.SysProcAttr = util.Config.GetSysProcAttr() + + return cmd +} + +func (c SvnClient) run(r GitRepository, targetDir GitRepositoryDirType, args ...string) error { + keyInstallation, err := c.keyInstaller.Install(r.Repository.SSHKey, db.AccessKeyRoleGit, r.Logger) + if err != nil { + return err + } + + defer keyInstallation.Destroy() //nolint: errcheck + + cmd := c.makeCmd(r, targetDir, keyInstallation, args...) + + finishLog := r.Logger.LogCmd(cmd) + defer finishLog() + + // The task log gets stderr through LogCmd; the error keeps its end too, + // for callers whose logger discards it, such as the repository API. + stderr := &svnStderrTail{} + if cmd.Stderr != nil { + cmd.Stderr = io.MultiWriter(cmd.Stderr, stderr) + } else { + cmd.Stderr = stderr + } + + return svnError(cmd.Run(), stderr.buf) +} + +func (c SvnClient) output(r GitRepository, targetDir GitRepositoryDirType, args ...string) (out string, err error) { + keyInstallation, err := c.keyInstaller.Install(r.Repository.SSHKey, db.AccessKeyRoleGit, r.Logger) + if err != nil { + return + } + + defer keyInstallation.Destroy() //nolint: errcheck + + bytes, err := c.makeCmd(r, targetDir, keyInstallation, args...).Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + err = svnError(err, exitErr.Stderr) + } + return + } + out = strings.TrimSpace(string(bytes)) + return +} + +// svnStderrMax bounds the stderr kept for an error. svn writes a few short +// lines when it fails; a long checkout can write much more before that. +const svnStderrMax = 1024 + +// svnStderrTail keeps the end of what svn writes to stderr. +type svnStderrTail struct { + buf []byte +} + +func (t *svnStderrTail) Write(p []byte) (int, error) { + t.buf = append(t.buf, p...) + if len(t.buf) > svnStderrMax { + t.buf = t.buf[len(t.buf)-svnStderrMax:] + } + return len(p), nil +} + +// svnUserinfo matches the userinfo of a URL quoted in an svn message. +var svnUserinfo = regexp.MustCompile(`(\w[\w+.-]*://)[^\s/@']*@`) + +// svnError adds what svn said to a failed command's error, which is only an +// exit status, with the userinfo of quoted URLs removed. svn never prints a +// password, but a URL may carry one typed into it. +func svnError(err error, stderr []byte) error { + if err == nil { + return nil + } + + lines := []string{} + for _, line := range strings.Split(string(stderr), "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, svnUserinfo.ReplaceAllString(line, "$1")) + } + } + + if len(lines) == 0 { + return err + } + + return fmt.Errorf("%w: %s", err, strings.Join(lines, " / ")) +} + +func (c SvnClient) Clone(r GitRepository) error { + r.Logger.Log("Checking out Subversion repository " + r.Repository.GetRedactedGitURL()) + + branchURL, err := svnBranchURL(r) + if err != nil { + return err + } + + var dirName string + if r.TmpDirName == "" { + dirName = r.Repository.GetCheckoutDirName(r.TemplateID) + } else { + dirName = r.TmpDirName + } + + targetPath := r.GetFullPath() + if err := os.MkdirAll(targetPath, 0755); err != nil { + return err + } + if err := util.ChownDir(targetPath); err != nil { + return err + } + + return c.run(r, GitRepositoryTmpPath, "checkout", "--", svnPeg(branchURL), dirName) +} + +// Pull brings the working copy back to the checked out revision, then +// updates it. svn update merges into local modifications and reports a +// conflict as success, and files a task leaves in the working copy, such as a +// generated playbook or role, would be picked up by the next task of the +// template. Reverting first also unschedules additions, whose files the +// cleanup then removes with the other unversioned and ignored files. Unlike +// git pull, untracked files are not kept. +func (c SvnClient) Pull(r GitRepository) error { + r.Logger.Log("Updating Subversion repository " + r.Repository.GetRedactedGitURL()) + + if err := c.run(r, GitRepositoryFullPath, "revert", "--recursive", "."); err != nil { + return err + } + if err := c.run(r, GitRepositoryFullPath, "cleanup", "--remove-unversioned", "--remove-ignored", "."); err != nil { + return err + } + return c.run(r, GitRepositoryFullPath, "update") +} + +func (c SvnClient) Checkout(r GitRepository, target string) error { + r.Logger.Log("Updating Subversion repository to revision " + target) + + if target == "" { + return fmt.Errorf("task commit hash is empty") + } + if err := svn.ValidateRevision(target, "task"); err != nil { + return err + } + + return c.run(r, GitRepositoryFullPath, "update", "--revision", target) +} + +// CanBePulled reports whether the working copy can be updated in place: it is +// a working copy of the branch. Pull removes local changes first; a working +// copy svn cannot revert or clean, such as one left locked by an interrupted +// command, makes Pull fail and is checked out again by the caller. +func (c SvnClient) CanBePulled(r GitRepository) bool { + branchURL, err := svnBranchURL(r) + if err != nil { + return false + } + + // Repository edits clear the cache of this server only; a runner may + // still hold a working copy of the URL the repository had before. + wcURL, err := c.output(r, GitRepositoryFullPath, "info", "--show-item", "url") + return err == nil && svn.SameURL(wcURL, branchURL) +} + +type svnLog struct { + Entries []struct { + Revision string `xml:"revision,attr"` + Msg string `xml:"msg"` + } `xml:"logentry"` +} + +func (c SvnClient) GetLastCommitMessage(r GitRepository) (msg string, err error) { + r.Logger.Log("Get current commit message") + + revision, err := c.GetLastCommitHash(r) + if err != nil { + return + } + + out, err := c.output(r, GitRepositoryFullPath, "log", "--xml", "--limit", "1", "--revision", revision) + if err != nil { + return + } + + var entries svnLog + if err = xml.Unmarshal([]byte(out), &entries); err != nil { + return + } + + if len(entries.Entries) == 0 { + return + } + + // show-branch prints the subject line only; do the same. + msg, _, _ = strings.Cut(strings.TrimSpace(entries.Entries[0].Msg), "\n") + msg = truncateCommitMessage(msg) + + return +} + +// GetLastCommitHash returns the last revision which changed the checked out +// path, not the revision of the whole repository, so a commit elsewhere in the +// repository does not look like a change to this one. +func (c SvnClient) GetLastCommitHash(r GitRepository) (hash string, err error) { + r.Logger.Log("Get current commit hash") + return c.output(r, GitRepositoryFullPath, "info", "--show-item", "last-changed-revision") +} + +func (c SvnClient) GetLastRemoteCommitHash(r GitRepository) (hash string, err error) { + branchURL, err := svnBranchURL(r) + if err != nil { + return + } + + return c.output(r, GitRepositoryTmpPath, + "info", "--show-item", "last-changed-revision", "--", svnPeg(branchURL)) +} + +// GetRemoteBranches lists the branch paths of the repository root. In the +// conventional layout these are trunk and the children of branches and tags; +// otherwise every directory of the root is a branch. +func (c SvnClient) GetRemoteBranches(r GitRepository) ([]string, error) { + root := strings.TrimRight(r.Repository.GetGitURL(false), "/") + + top, err := c.listDirs(r, root) + if err != nil { + return nil, err + } + + // trunk first: it is the branch most repositories are used with. + branches := []string{} + standard := false + + for _, dir := range top { + switch dir { + case "trunk": + branches = append([]string{dir}, branches...) + standard = true + case "branches", "tags": + children, err := c.listDirs(r, root+"/"+dir) + if err != nil { + return nil, err + } + for _, child := range children { + branches = append(branches, dir+"/"+child) + } + standard = true + } + } + + if !standard { + return top, nil + } + + return branches, nil +} + +// listDirs returns the names of the directories directly under the URL. +func (c SvnClient) listDirs(r GitRepository, dirURL string) ([]string, error) { + out, err := c.output(r, GitRepositoryTmpPath, "list", "--", svnPeg(dirURL)) + if err != nil { + return nil, err + } + + dirs := []string{} + for _, line := range strings.Split(out, "\n") { + if name, ok := strings.CutSuffix(strings.TrimSpace(line), "/"); ok && name != "" { + dirs = append(dirs, name) + } + } + return dirs, nil +} diff --git a/db_lib/SvnClient_test.go b/db_lib/SvnClient_test.go new file mode 100644 index 0000000000..b8358ab895 --- /dev/null +++ b/db_lib/SvnClient_test.go @@ -0,0 +1,580 @@ +package db_lib + +import ( + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/semaphoreui/semaphore/db" + "github.com/semaphoreui/semaphore/pkg/ssh" + "github.com/semaphoreui/semaphore/pkg/task_logger" + "github.com/semaphoreui/semaphore/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func requireSvn(t *testing.T) { + t.Helper() + for _, bin := range []string{"svn", "svnadmin"} { + if _, err := exec.LookPath(bin); err != nil { + t.Skip(bin + " is not installed") + } + } +} + +func svnRun(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("svn", append([]string{"--non-interactive"}, args...)...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) +} + +// svnFixture is a Subversion repository in the conventional layout, and a +// working copy of its trunk to commit to it with. Revision 1 creates trunk, +// branches and tags, so the first commit is revision 2. +type svnFixture struct { + root string // repository directory, for svnserve + url string // file:// URL of the repository root + wc string +} + +func newSvnFixture(t *testing.T) svnFixture { + t.Helper() + requireSvn(t) + + dir := t.TempDir() + root := filepath.Join(dir, "repo") + out, err := exec.Command("svnadmin", "create", root).CombinedOutput() + require.NoError(t, err, string(out)) + + f := svnFixture{ + root: root, + url: "file://" + filepath.ToSlash(root), + wc: filepath.Join(dir, "wc"), + } + + svnRun(t, dir, "mkdir", "-m", "layout", f.url+"/trunk", f.url+"/branches", f.url+"/tags") + svnRun(t, dir, "checkout", f.url+"/trunk", f.wc) + + return f +} + +// commit adds or changes a file of the trunk. +func (f svnFixture) commit(t *testing.T, file, content, msg string) { + t.Helper() + path := filepath.Join(f.wc, file) + _, statErr := os.Stat(path) + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + if os.IsNotExist(statErr) { + svnRun(t, f.wc, "add", file) + } + svnRun(t, f.wc, "commit", "-m", msg) +} + +// branch copies the trunk to the path, as svn copy does for a branch or a tag. +func (f svnFixture) branch(t *testing.T, path string) { + t.Helper() + svnRun(t, f.wc, "copy", "-m", "branch "+path, f.url+"/trunk", f.url+"/"+path+"@") +} + +func newTestSvnRepo(t *testing.T, url, branch string) GitRepository { + t.Helper() + return GitRepository{ + Repository: db.Repository{ + ProjectID: 1, + GitURL: url, + GitBranch: branch, + SSHKey: db.AccessKey{Type: db.AccessKeyNone}, + }, + Logger: task_logger.NopLogger{}, + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + return string(content) +} + +func TestSvnClient_CloneUpdateCheckout(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.commit(t, "site.yml", "v2", "second revision\n\nwith a body") + + client := CreateSvnClient(nopKeyInstaller{}) + r := newTestSvnRepo(t, f.url, "trunk") + + require.NoError(t, client.Clone(r)) + assert.Equal(t, "v2", readFile(t, filepath.Join(r.GetFullPath(), "site.yml"))) + + hash, err := client.GetLastCommitHash(r) + require.NoError(t, err) + assert.Equal(t, "2", hash) + + msg, err := client.GetLastCommitMessage(r) + require.NoError(t, err) + assert.Equal(t, "second revision", msg) + + assert.True(t, client.CanBePulled(r)) + + f.commit(t, "site.yml", "v3", "third revision") + + remote, err := client.GetLastRemoteCommitHash(r) + require.NoError(t, err) + assert.Equal(t, "3", remote) + + require.NoError(t, client.Pull(r)) + hash, err = client.GetLastCommitHash(r) + require.NoError(t, err) + assert.Equal(t, "3", hash) + + // A task re-run at the revision it recorded. + require.NoError(t, client.Checkout(r, "2")) + assert.Equal(t, "v2", readFile(t, filepath.Join(r.GetFullPath(), "site.yml"))) +} + +// svn refuses non-ASCII file names under the C locale on Linux; macOS takes +// file names as UTF-8 whatever the locale. +func TestSvnClient_NonASCIIFileName(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.commit(t, "déploiement.yml", "v2", "accentué") + + client := CreateSvnClient(nopKeyInstaller{}) + r := newTestSvnRepo(t, f.url, "trunk") + + require.NoError(t, client.Clone(r)) + assert.Equal(t, "v2", readFile(t, filepath.Join(r.GetFullPath(), "déploiement.yml"))) + + msg, err := client.GetLastCommitMessage(r) + require.NoError(t, err) + assert.Equal(t, "accentué", msg) +} + +func TestSvnClient_Locale(t *testing.T) { + setupGitClientTest(t) + r := newTestSvnRepo(t, "svn://svn.example.com/repo", "trunk") + + t.Run("utf-8 when no locale is set", func(t *testing.T) { + cmd := SvnClient{}.makeCmd(r, GitRepositoryTmpPath, ssh.AccessKeyInstallation{}, "info") + assert.Contains(t, cmd.Env, "LC_CTYPE=C.UTF-8") + }) + + t.Run("the administrator locale wins", func(t *testing.T) { + util.Config.EnvVars = map[string]string{"LANG": "de_CH.UTF-8"} + cmd := SvnClient{}.makeCmd(r, GitRepositoryTmpPath, ssh.AccessKeyInstallation{}, "info") + assert.NotContains(t, cmd.Env, "LC_CTYPE=C.UTF-8") + assert.Contains(t, cmd.Env, "LANG=de_CH.UTF-8") + }) +} + +func TestSvnClient_Branch(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.commit(t, "site.yml", "v2", "second") + f.branch(t, "branches/release") + f.commit(t, "site.yml", "v4", "trunk moves on") + + client := CreateSvnClient(nopKeyInstaller{}) + r := newTestSvnRepo(t, f.url, "branches/release") + + require.NoError(t, client.Clone(r)) + assert.Equal(t, "v2", readFile(t, filepath.Join(r.GetFullPath(), "site.yml"))) + + remote, err := client.GetLastRemoteCommitHash(r) + require.NoError(t, err) + assert.Equal(t, "3", remote, "the commit to trunk is not a change of the branch") +} + +// svn reads the text after the last "@" of a URL as a peg revision. +func TestSvnClient_BranchWithAt(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.commit(t, "site.yml", "v2", "second") + f.branch(t, "tags/v1@2") + + client := CreateSvnClient(nopKeyInstaller{}) + r := newTestSvnRepo(t, f.url, "tags/v1@2") + + require.NoError(t, client.Clone(r)) + assert.Equal(t, "v2", readFile(t, filepath.Join(r.GetFullPath(), "site.yml"))) + assert.True(t, client.CanBePulled(r)) +} + +func TestSvnClient_CommitOutsidePathIsNotAChange(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.commit(t, "site.yml", "v2", "second") + + client := CreateSvnClient(nopKeyInstaller{}) + r := newTestSvnRepo(t, f.url, "trunk") + + f.branch(t, "branches/unrelated") + + remote, err := client.GetLastRemoteCommitHash(r) + require.NoError(t, err) + assert.Equal(t, "2", remote) +} + +func TestSvnClient_GetRemoteBranches(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.branch(t, "branches/release") + f.branch(t, "tags/v1") + + client := CreateSvnClient(nopKeyInstaller{}) + + t.Run("conventional layout", func(t *testing.T) { + branches, err := client.GetRemoteBranches(newTestSvnRepo(t, f.url, "trunk")) + require.NoError(t, err) + assert.Equal(t, []string{"trunk", "branches/release", "tags/v1"}, branches) + }) + + t.Run("other layout", func(t *testing.T) { + branches, err := client.GetRemoteBranches(newTestSvnRepo(t, f.url+"/branches", "release")) + require.NoError(t, err) + assert.Equal(t, []string{"release"}, branches) + }) +} + +func TestSvnClient_CanBePulled(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.commit(t, "site.yml", "v2", "second") + f.branch(t, "branches/release") + + client := CreateSvnClient(nopKeyInstaller{}) + + t.Run("missing working copy", func(t *testing.T) { + assert.False(t, client.CanBePulled(newTestSvnRepo(t, f.url, "trunk"))) + }) + + t.Run("other branch", func(t *testing.T) { + r := newTestSvnRepo(t, f.url, "trunk") + r.TmpDirName = "other" + require.NoError(t, client.Clone(r)) + r.Repository.GitBranch = "branches/release" + assert.False(t, client.CanBePulled(r)) + }) +} + +// Pull brings the working copy back to the checked out revision before +// updating: local modifications, scheduled additions, unversioned and ignored +// files a task left behind do not reach the next task of the template. +func TestSvnClient_PullRestoresWorkingCopy(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.commit(t, "site.yml", "v2", "second") + svnRun(t, f.wc, "update") + svnRun(t, f.wc, "propset", "svn:ignore", "*.retry", ".") + svnRun(t, f.wc, "commit", "-m", "ignore retry files") + + client := CreateSvnClient(nopKeyInstaller{}) + r := newTestSvnRepo(t, f.url, "trunk") + require.NoError(t, client.Clone(r)) + wc := r.GetFullPath() + + require.NoError(t, os.WriteFile(filepath.Join(wc, "site.yml"), []byte("local"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(wc, "added.yml"), []byte("x"), 0644)) + svnRun(t, wc, "add", "added.yml") + require.NoError(t, os.WriteFile(filepath.Join(wc, "generated.yml"), []byte("x"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(wc, "site.retry"), []byte("x"), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(wc, "roles", "generated", "tasks"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(wc, "roles", "generated", "tasks", "main.yml"), []byte("x"), 0644)) + + f.commit(t, "site.yml", "v3", "third") + + require.True(t, client.CanBePulled(r)) + require.NoError(t, client.Pull(r)) + + assert.Equal(t, "v3", readFile(t, filepath.Join(wc, "site.yml"))) + assert.NoFileExists(t, filepath.Join(wc, "added.yml"), "scheduled addition") + assert.NoFileExists(t, filepath.Join(wc, "generated.yml"), "unversioned file") + assert.NoFileExists(t, filepath.Join(wc, "site.retry"), "ignored file") + assert.NoDirExists(t, filepath.Join(wc, "roles"), "unversioned directory") + + hash, err := client.GetLastCommitHash(r) + require.NoError(t, err) + assert.Equal(t, "4", hash) +} + +// Each template checks the branch out in its own working copy, as for git: +// nothing a task does in it reaches another template. +func TestSvnClient_WorkingCopyPerTemplate(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.commit(t, "site.yml", "v2", "second") + + client := CreateSvnClient(nopKeyInstaller{}) + one := newTestSvnRepo(t, f.url, "trunk") + one.TemplateID = 1 + two := newTestSvnRepo(t, f.url, "trunk") + two.TemplateID = 2 + + require.NoError(t, client.Clone(one)) + require.NoError(t, os.WriteFile(filepath.Join(one.GetFullPath(), "leftover.yml"), []byte("x"), 0644)) + + f.commit(t, "site.yml", "v3", "third") + require.NoError(t, client.Clone(two)) + + assert.NotEqual(t, one.GetFullPath(), two.GetFullPath()) + assert.DirExists(t, filepath.Join(two.GetFullPath(), ".svn")) + assert.NoFileExists(t, filepath.Join(two.GetFullPath(), "leftover.yml")) + assert.Equal(t, "v3", readFile(t, filepath.Join(two.GetFullPath(), "site.yml"))) + assert.Equal(t, "v2", readFile(t, filepath.Join(one.GetFullPath(), "site.yml")), + "another template's update does not touch this working copy") +} + +// The playbook picker browses an svn branch the way it browses a git one: a +// scratch checkout, listed without its .svn directory. +func TestSvnClient_BrowseScratchCheckout(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + require.NoError(t, os.MkdirAll(filepath.Join(f.wc, "plays"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(f.wc, "roles", "web", "tasks"), 0755)) + for _, file := range []string{"site.yml", "plays/db.yaml", "roles/web/tasks/main.yml"} { + require.NoError(t, os.WriteFile(filepath.Join(f.wc, file), []byte("x"), 0644)) + } + svnRun(t, f.wc, "add", "--force", ".") + svnRun(t, f.wc, "commit", "-m", "playbooks") + + r := newTestSvnRepo(t, f.url, "trunk") + r.TmpDirName = "repository_0_browse_trunk" + r.Client = CreateSvnClient(nopKeyInstaller{}) + + require.NoError(t, r.CloneOrPull()) + require.NoError(t, r.CloneOrPull(), "the second browse updates the scratch checkout") + + files, err := FindRepositoryFiles(r.GetFullPath(), db.AppAnsible, "") + require.NoError(t, err) + assert.Equal(t, []string{"plays/db.yaml", "site.yml"}, files) + + dirs, err := FindRepositoryFiles(r.GetFullPath(), db.AppTerraform, "") + require.NoError(t, err) + assert.NotContains(t, dirs, ".svn") +} + +func TestSvnClient_RejectsInvalidBranch(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + + client := CreateSvnClient(nopKeyInstaller{}) + + for _, branch := range []string{"../escape", "trunk/../../escape", "-r1", "--config-dir=/tmp", "/abs", "a b"} { + t.Run(branch, func(t *testing.T) { + r := newTestSvnRepo(t, f.url, branch) + assert.Error(t, client.Clone(r)) + _, err := client.GetLastRemoteCommitHash(r) + assert.Error(t, err) + }) + } +} + +func TestSvnClient_RejectsInvalidCommit(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + + client := CreateSvnClient(nopKeyInstaller{}) + r := newTestSvnRepo(t, f.url, "trunk") + require.NoError(t, client.Clone(r)) + + for _, target := range []string{"", "HEAD", "abcdef1", "-1", "--config-dir=/tmp", "1:2"} { + t.Run(target, func(t *testing.T) { + assert.Error(t, client.Checkout(r, target)) + }) + } +} + +// startSvnserve serves the fixture with svnserve, readable only with the given +// credentials, and returns the svn:// URL of the repository root. +func startSvnserve(t *testing.T, f svnFixture, login, password string) string { + t.Helper() + if _, err := exec.LookPath("svnserve"); err != nil { + t.Skip("svnserve is not installed") + } + + conf := "[general]\nanon-access = none\nauth-access = write\npassword-db = passwd\n" + require.NoError(t, os.WriteFile(filepath.Join(f.root, "conf", "svnserve.conf"), []byte(conf), 0644)) + passwd := fmt.Sprintf("[users]\n%s = %s\n", login, password) + require.NoError(t, os.WriteFile(filepath.Join(f.root, "conf", "passwd"), []byte(passwd), 0644)) + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := l.Addr().(*net.TCPAddr).Port + require.NoError(t, l.Close()) + + cmd := exec.Command("svnserve", "--daemon", "--foreground", + "--listen-host", "127.0.0.1", "--listen-port", fmt.Sprint(port), "--root", f.root) + require.NoError(t, cmd.Start()) + t.Cleanup(func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) + + addr := fmt.Sprintf("127.0.0.1:%d", port) + require.Eventually(t, func() bool { + c, err := net.Dial("tcp", addr) + if err == nil { + _ = c.Close() + } + return err == nil + }, 5*time.Second, 50*time.Millisecond) + + return "svn://" + addr +} + +func TestSvnClient_LoginPassword(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + f.commit(t, "site.yml", "v2", "second") + + const password = "s3cret pass" + url := startSvnserve(t, f, "deploy", password) + + client := CreateSvnClient(nopKeyInstaller{}) + + withKey := func(login, password string) GitRepository { + r := newTestSvnRepo(t, url, "trunk") + r.Repository.SSHKey = db.AccessKey{ + Type: db.AccessKeyLoginPassword, + LoginPassword: db.LoginPassword{Login: login, Password: password}, + } + return r + } + + t.Run("valid credentials", func(t *testing.T) { + r := withKey("deploy", password) + require.Equal(t, db.RepositorySVN, r.Repository.GetType()) + require.NoError(t, client.Clone(r)) + hash, err := client.GetLastCommitHash(r) + require.NoError(t, err) + assert.Equal(t, "2", hash) + assert.True(t, client.CanBePulled(r), "svn prints the working copy URL canonicalized") + }) + + t.Run("password is not passed as an argument", func(t *testing.T) { + r := withKey("deploy", password) + cmd := SvnClient{}.makeCmd(r, GitRepositoryTmpPath, ssh.AccessKeyInstallation{}, "info") + assert.NotContains(t, cmd.Args, password) + assert.Contains(t, cmd.Args, "--password-from-stdin") + assert.Contains(t, cmd.Args, "--no-auth-cache") + assert.Contains(t, cmd.Args, "--non-interactive") + }) + + t.Run("wrong password", func(t *testing.T) { + r := withKey("deploy", "wrong") + r.TmpDirName = "wrong" + assert.Error(t, client.Clone(r)) + }) + + t.Run("no credentials", func(t *testing.T) { + r := newTestSvnRepo(t, url, "trunk") + _, err := client.GetRemoteBranches(r) + assert.Error(t, err) + }) +} + +func TestSvnClient_SshTunnel(t *testing.T) { + setupGitClientTest(t) + util.Config.Ssh = &util.SshConfig{StrictHostKeyChecking: util.SshStrictHostKeyCheckingAcceptNew, KnownHostsFile: "/tmp/known_hosts"} + + r := newTestSvnRepo(t, "svn+ssh://svn.example.com/repo/trunk", "HEAD") + installation := ssh.AccessKeyInstallation{SSHAgent: &ssh.Agent{SocketFile: "/tmp/agent.sock"}} + + cmd := SvnClient{}.makeCmd(r, GitRepositoryTmpPath, installation, "info") + + assert.Contains(t, cmd.Env, "SSH_AUTH_SOCK=/tmp/agent.sock") + assert.Contains(t, cmd.Env, "SVN_SSH=ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/known_hosts") + for _, v := range cmd.Env { + assert.NotContains(t, v, "GIT_SSH_COMMAND=") + } +} + +func TestSvnError(t *testing.T) { + exit := errors.New("exit status 1") + + tests := []struct { + name string + stderr string + expected string + }{ + {"no stderr", "", "exit status 1"}, + { + "svn lines", + "svn: E170013: Unable to connect to a repository at URL 'svn://host/trunk'\nsvn: E170001: Authentication error\n", + "exit status 1: svn: E170013: Unable to connect to a repository at URL 'svn://host/trunk' / svn: E170001: Authentication error", + }, + { + "userinfo removed", + "svn: E170013: Unable to connect to a repository at URL 'svn+ssh://deploy:secret@host/repo'", + "exit status 1: svn: E170013: Unable to connect to a repository at URL 'svn+ssh://host/repo'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := svnError(exit, []byte(tt.stderr)) + assert.EqualError(t, err, tt.expected) + assert.ErrorIs(t, err, exit) + }) + } + + assert.NoError(t, svnError(nil, []byte("svn: warning"))) +} + +func TestSvnStderrTail(t *testing.T) { + tail := &svnStderrTail{} + _, _ = tail.Write([]byte(strings.Repeat("a", svnStderrMax))) + _, _ = tail.Write([]byte("svn: E000001: last")) + assert.Len(t, tail.buf, svnStderrMax) + assert.True(t, strings.HasSuffix(string(tail.buf), "svn: E000001: last")) +} + +// Failures reach callers whose logger discards output, such as the +// repository API, with svn's explanation instead of an exit status alone. +func TestSvnClient_ErrorCarriesStderr(t *testing.T) { + setupGitClientTest(t) + f := newSvnFixture(t) + + client := CreateSvnClient(nopKeyInstaller{}) + + r := newTestSvnRepo(t, f.url, "branches/missing") + err := client.Clone(r) + require.Error(t, err) + assert.Contains(t, err.Error(), "svn: E") + + _, err = client.GetLastRemoteCommitHash(r) + require.Error(t, err) + assert.Contains(t, err.Error(), "svn: E") +} + +func TestCreateDefaultGitClient_RoutesSubversion(t *testing.T) { + setupGitClientTest(t) + + tests := []struct { + url string + expected GitClient + }{ + {"svn://svn.example.com/repo/trunk", SvnClient{}}, + {"svn+ssh://svn.example.com/repo/trunk", SvnClient{}}, + {"https://github.com/semaphoreui/semaphore.git", CmdGitClient{}}, + {"git@github.com:semaphoreui/semaphore.git", CmdGitClient{}}, + } + + client, ok := CreateDefaultGitClient(nopKeyInstaller{}).(repositoryTypeClient) + require.True(t, ok) + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + r := GitRepository{Repository: db.Repository{GitURL: tt.url}} + assert.IsType(t, tt.expected, client.client(r)) + }) + } +} diff --git a/db_lib/repositoryTypeClient.go b/db_lib/repositoryTypeClient.go new file mode 100644 index 0000000000..16042fc9f9 --- /dev/null +++ b/db_lib/repositoryTypeClient.go @@ -0,0 +1,49 @@ +package db_lib + +// repositoryTypeClient picks the client for each call from the repository it +// is given: the svn client for a Subversion URL, the git client otherwise. The +// URL scheme is the only routing key, so git and Subversion repositories live +// on one server without a setting or a schema change. +type repositoryTypeClient struct { + git GitClient + svn GitClient +} + +func (c repositoryTypeClient) client(r GitRepository) GitClient { + if r.Repository.IsSubversion() { + return c.svn + } + return c.git +} + +func (c repositoryTypeClient) Clone(r GitRepository) error { + return c.client(r).Clone(r) +} + +func (c repositoryTypeClient) Pull(r GitRepository) error { + return c.client(r).Pull(r) +} + +func (c repositoryTypeClient) Checkout(r GitRepository, target string) error { + return c.client(r).Checkout(r, target) +} + +func (c repositoryTypeClient) CanBePulled(r GitRepository) bool { + return c.client(r).CanBePulled(r) +} + +func (c repositoryTypeClient) GetLastCommitMessage(r GitRepository) (string, error) { + return c.client(r).GetLastCommitMessage(r) +} + +func (c repositoryTypeClient) GetLastCommitHash(r GitRepository) (string, error) { + return c.client(r).GetLastCommitHash(r) +} + +func (c repositoryTypeClient) GetLastRemoteCommitHash(r GitRepository) (string, error) { + return c.client(r).GetLastRemoteCommitHash(r) +} + +func (c repositoryTypeClient) GetRemoteBranches(r GitRepository) ([]string, error) { + return c.client(r).GetRemoteBranches(r) +} diff --git a/deployment/docker/job/Dockerfile b/deployment/docker/job/Dockerfile index ec542a84be..66ffb77af8 100644 --- a/deployment/docker/job/Dockerfile +++ b/deployment/docker/job/Dockerfile @@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3-pip \ python3-venv \ sshpass \ + subversion \ tini \ curl \ wget \ diff --git a/deployment/docker/runner/Dockerfile b/deployment/docker/runner/Dockerfile index 611a69b8ea..3aeb8d9b4a 100644 --- a/deployment/docker/runner/Dockerfile +++ b/deployment/docker/runner/Dockerfile @@ -54,7 +54,7 @@ FROM alpine:3.24 ARG TARGETARCH="amd64" RUN apk add --no-cache -U \ - bash curl git gnupg mysql-client openssh-client-default python3 python3-dev py3-pip rsync sshpass tar tini tzdata unzip wget zip && \ + bash curl git gnupg mysql-client openssh-client-default python3 python3-dev py3-pip rsync sshpass subversion tar tini tzdata unzip wget zip && \ rm -rf /var/cache/apk/* && \ adduser -D -u 1001 -G root semaphore && \ mkdir -p /tmp/semaphore && \ diff --git a/deployment/docker/server/Dockerfile b/deployment/docker/server/Dockerfile index a2eee46b61..7a9415cc41 100644 --- a/deployment/docker/server/Dockerfile +++ b/deployment/docker/server/Dockerfile @@ -58,7 +58,7 @@ ENV ANSIBLE_VERSION=${ANSIBLE_VERSION} ARG ANSIBLE_VENV_PATH=/opt/semaphore/apps/ansible/${ANSIBLE_VERSION}/venv RUN apk add --no-cache -U \ - bash curl git gnupg mysql-client openssh-client-default python3 py3-pip rsync sshpass tar tini tzdata unzip wget zip jq && \ + bash curl git gnupg mysql-client openssh-client-default python3 py3-pip rsync sshpass subversion tar tini tzdata unzip wget zip jq && \ rm -rf /var/cache/apk/* && \ adduser -D -u 1001 -G root semaphore && \ mkdir -p /tmp/semaphore && \ diff --git a/pkg/git_branch_test.go b/pkg/git_branch_test.go index e6519ff144..f66cbb5d68 100644 --- a/pkg/git_branch_test.go +++ b/pkg/git_branch_test.go @@ -64,7 +64,7 @@ func TestTask_ValidateNewTask_InvalidBranch_ReturnsError(t *testing.T) { GitBranch: &branch, } - err := task.ValidateNewTask(db.Template{}) + err := task.ValidateNewTask(db.Template{}, db.Repository{}) assert.EqualError(t, err, "task branch name is invalid") } @@ -75,7 +75,7 @@ func TestTask_ValidateNewTask_ValidBranch_ReturnsNoError(t *testing.T) { GitBranch: &branch, } - err := task.ValidateNewTask(db.Template{}) + err := task.ValidateNewTask(db.Template{}, db.Repository{}) assert.NoError(t, err) } @@ -116,7 +116,7 @@ func TestTask_ValidateNewTask_InvalidCommitHash_ReturnsError(t *testing.T) { CommitHash: &hash, } - err := task.ValidateNewTask(db.Template{}) + err := task.ValidateNewTask(db.Template{}, db.Repository{}) assert.EqualError(t, err, "task commit hash is invalid") } diff --git a/pkg/svn/svn.go b/pkg/svn/svn.go new file mode 100644 index 0000000000..4e07b465ed --- /dev/null +++ b/pkg/svn/svn.go @@ -0,0 +1,68 @@ +package svn + +import ( + "net/url" + "regexp" + "strings" + + "github.com/semaphoreui/semaphore/pkg/common_errors" +) + +const svnSSHScheme = "svn+ssh://" + +// IsSvnSSHURL reports whether the URL tunnels Subversion through ssh. The +// scheme is case-insensitive (RFC 3986). It is matched apart from the other +// schemes because "+" is not a word character. +func IsSvnSSHURL(rawURL string) bool { + return len(rawURL) >= len(svnSSHScheme) && strings.EqualFold(rawURL[:len(svnSSHScheme)], svnSSHScheme) +} + +// revisionPattern matches a revision number, which a task of a Subversion +// repository records in place of a commit hash. +var revisionPattern = regexp.MustCompile(`^[0-9]{1,10}$`) + +// ValidateRevision rejects a revision that is not a revision number, which +// keeps it from carrying svn options, revision keywords or ranges. +func ValidateRevision(revision string, objectName string) error { + if revision == "" { + return nil + } + + if !revisionPattern.MatchString(revision) { + return common_errors.NewValidationError(objectName + " commit hash is invalid") + } + + return nil +} + +// defaultSvnPort is the svnserve port, which svn drops from the URLs it prints. +const defaultSvnPort = "3690" + +// SameURL reports whether two URLs name the same location once canonicalized +// the way svn canonicalizes the URL of a working copy: scheme and host in +// lower case, no svnserve default port, path percent-encoded and without a +// trailing slash. +func SameURL(a, b string) bool { + return canonicalURL(a) == canonicalURL(b) +} + +func canonicalURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + + scheme := strings.ToLower(u.Scheme) + + host := strings.ToLower(u.Hostname()) + if port := u.Port(); port != "" && !(scheme == "svn" && port == defaultSvnPort) { + host += ":" + port + } + + user := "" + if u.User != nil { + user = u.User.String() + "@" + } + + return scheme + "://" + user + host + strings.TrimRight(u.Path, "/") +} diff --git a/pkg/svn/svn_test.go b/pkg/svn/svn_test.go new file mode 100644 index 0000000000..051677a734 --- /dev/null +++ b/pkg/svn/svn_test.go @@ -0,0 +1,77 @@ +package svn + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsSvnSSHURL(t *testing.T) { + tests := []struct { + url string + expected bool + }{ + {"svn+ssh://user@svn.example.com/repo", true}, + {"SVN+SSH://svn.example.com/repo", true}, + {"svn://svn.example.com/repo", false}, + {"git+ssh://git@example.com/repo.git", false}, + {"svn+ssh:/", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + assert.Equal(t, tt.expected, IsSvnSSHURL(tt.url)) + }) + } +} + +func TestValidateRevision(t *testing.T) { + tests := []struct { + name string + revision string + wantErr bool + }{ + {"empty is allowed", "", false}, + {"revision number", "42", false}, + {"HEAD keyword", "HEAD", true}, + {"git hash", "a1b2c3d", true}, + {"option injection", "--config-dir=/tmp", true}, + {"negative", "-1", true}, + {"range", "1:2", true}, + {"too long", "12345678901", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRevision(tt.revision, "task") + if tt.wantErr { + assert.EqualError(t, err, "task commit hash is invalid") + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestSameURL(t *testing.T) { + tests := []struct { + name string + a string + b string + expected bool + }{ + {"default svnserve port", "svn://127.0.0.1:3690/trunk", "svn://127.0.0.1/trunk", true}, + {"case and trailing slash", "SVN://Host.EXAMPLE:3690/a%20b/", "svn://host.example/a%20b", true}, + {"encoded path", "svn+ssh://host/a b", "svn+ssh://host/a%20b", true}, + {"ssh port is kept", "svn+ssh://user@host:22/trunk", "svn+ssh://user@host:22/trunk", true}, + {"other port", "svn://host:3691/x", "svn://host/x", false}, + {"other branch", "svn://host/trunk", "svn://host/branches/release", false}, + {"other host", "svn://host/trunk", "svn://other/trunk", false}, + {"other user", "svn+ssh://a@host/trunk", "svn+ssh://b@host/trunk", false}, + {"file url", "file:///srv/repo/trunk", "file:///srv/repo/trunk", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, SameURL(tt.a, tt.b)) + }) + } +} diff --git a/services/tasks/TaskPool.go b/services/tasks/TaskPool.go index 67cfa40e59..966b2d74f9 100644 --- a/services/tasks/TaskPool.go +++ b/services/tasks/TaskPool.go @@ -1262,7 +1262,12 @@ func (p *TaskPool) AddTaskFrom( return } - err = taskObj.ValidateNewTask(tpl) + repository, err := p.store.GetRepository(projectID, tpl.RepositoryID) + if err != nil { + return + } + + err = taskObj.ValidateNewTask(tpl, repository) if err != nil { return } diff --git a/web/src/components/RepositoryForm.vue b/web/src/components/RepositoryForm.vue index 6af9f36f7b..b0c4609a41 100644 --- a/web/src/components/RepositoryForm.vue +++ b/web/src/components/RepositoryForm.vue @@ -47,6 +47,16 @@ style="font-weight: bold;" :key="x" >{{ x }} + svn: + {{ x }} local: { it('returns null for unknown URL schemes', () => { expect(getRepositoryUrlType('ftp://example.com/repo')).to.equal(null); - expect(getRepositoryUrlType('svn://example.com/repo')).to.equal(null); + }); + + it('returns svn for svn:// URLs', () => { + expect(getRepositoryUrlType('svn://svn.example.com/repo/trunk')).to.equal('svn'); + }); + + it('returns svn+ssh for svn+ssh:// URLs', () => { + expect(getRepositoryUrlType('svn+ssh://user@svn.example.com/repo/trunk')).to.equal('svn+ssh'); + }); + + it('keeps reading other x+y:// URLs as ssh', () => { + expect(getRepositoryUrlType('git+ssh://git@github.com/user/repo')).to.equal('ssh'); }); });