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');
});
});