From 3578056feb505c76fcd21c0b2a64089d3055f1cd Mon Sep 17 00:00:00 2001 From: lduchosal Date: Sun, 4 Oct 2026 13:12:05 +0200 Subject: [PATCH 1/9] feat(repository): add Subversion repository support Repositories with an svn:// or svn+ssh:// URL are served by a new SvnClient that shells out to the svn binary. git and Subversion repositories live on one server: the client is chosen per call from the repository URL, with no setting and no schema change. - The URL is the repository root and the branch is a path under it (trunk, branches/x, tags/y), so templates and tasks select an svn branch the way they select a git branch - The last revision that changed the branch path stands in for the commit hash; a task re-run checks out that revision - Route svn repositories in db_lib/repositoryTypeClient.go behind the unchanged CreateDefaultGitClient - Add pkg/svn validators, mirroring pkg/git; validate a task commit hash by the type of the template repository - Pass the password on stdin, never as an argument; use --no-auth-cache and --non-interactive; svn+ssh reuses the ssh command, agent and host configs of git - Accept svn and svn+ssh URLs in the repository form - Install subversion in the CI test job --- .github/workflows/dev.yml | 3 + db/Repository.go | 30 ++ db/Repository_test.go | 73 ++++ db/Task.go | 6 +- db/playbook_path_test.go | 4 +- db_lib/GitClientFactory.go | 9 + db_lib/SvnClient.go | 344 +++++++++++++++++++ db_lib/SvnClient_test.go | 405 +++++++++++++++++++++++ db_lib/repositoryTypeClient.go | 49 +++ pkg/git_branch_test.go | 6 +- pkg/svn/svn.go | 35 ++ pkg/svn/svn_test.go | 53 +++ services/tasks/TaskPool.go | 7 +- web/src/components/RepositoryForm.vue | 12 +- web/src/lib/repositoryUrl.js | 10 +- web/tests/unit/lib/repositoryUrl.spec.js | 13 +- 16 files changed, 1047 insertions(+), 12 deletions(-) create mode 100644 db_lib/SvnClient.go create mode 100644 db_lib/SvnClient_test.go create mode 100644 db_lib/repositoryTypeClient.go create mode 100644 pkg/svn/svn.go create mode 100644 pkg/svn/svn_test.go diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index c171c38828..9440eb287f 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' + - 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 5f2d87b33d..f63f20ffba 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 @@ -214,6 +220,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 { @@ -257,3 +267,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 183e95b4a2..23c5033310 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 9efd2600a6..ba997f6c4e 100644 --- a/db/Task.go +++ b/db/Task.go @@ -190,7 +190,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 @@ -198,7 +200,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..8c72392dab --- /dev/null +++ b/db_lib/SvnClient.go @@ -0,0 +1,344 @@ +package db_lib + +import ( + "encoding/xml" + "fmt" + "net/url" + "os" + "os/exec" + "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) + + 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() + + return cmd.Run() +} + +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 { + return + } + out = strings.TrimSpace(string(bytes)) + return +} + +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) +} + +func (c SvnClient) Pull(r GitRepository) error { + r.Logger.Log("Updating Subversion repository " + r.Repository.GetRedactedGitURL()) + + 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 checkout of the same branch and has no local modifications. Unlike git +// pull, svn update merges into local modifications and reports a conflict as +// success, so a modified working copy is checked out again instead. +func (c SvnClient) CanBePulled(r GitRepository) bool { + branchURL, err := svnBranchURL(r) + if err != nil { + return false + } + + // svn prints the URL percent-encoded. + wcURL, err := c.output(r, GitRepositoryFullPath, "info", "--show-item", "url") + if err != nil { + return false + } + if decoded, err := url.PathUnescape(wcURL); err != nil || decoded != branchURL { + return false + } + + status, err := c.output(r, GitRepositoryFullPath, "status", "--quiet") + return err == nil && status == "" +} + +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..faa106b39f --- /dev/null +++ b/db_lib/SvnClient_test.go @@ -0,0 +1,405 @@ +package db_lib + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "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"))) +} + +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("local modification", func(t *testing.T) { + r := newTestSvnRepo(t, f.url, "trunk") + r.TmpDirName = "modified" + require.NoError(t, client.Clone(r)) + require.NoError(t, os.WriteFile(filepath.Join(r.GetFullPath(), "site.yml"), []byte("local"), 0644)) + assert.False(t, client.CanBePulled(r)) + }) + + 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)) + }) + + t.Run("unversioned file", func(t *testing.T) { + r := newTestSvnRepo(t, f.url, "trunk") + r.TmpDirName = "unversioned" + require.NoError(t, client.Clone(r)) + require.NoError(t, os.WriteFile(filepath.Join(r.GetFullPath(), "generated.retry"), []byte("x"), 0644)) + assert.True(t, client.CanBePulled(r)) + }) +} + +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) + }) + + 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 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/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..bcfc1b0a62 --- /dev/null +++ b/pkg/svn/svn.go @@ -0,0 +1,35 @@ +package svn + +import ( + "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 +} diff --git a/pkg/svn/svn_test.go b/pkg/svn/svn_test.go new file mode 100644 index 0000000000..00adcf61dc --- /dev/null +++ b/pkg/svn/svn_test.go @@ -0,0 +1,53 @@ +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) + } + }) + } +} diff --git a/services/tasks/TaskPool.go b/services/tasks/TaskPool.go index b64b9fd5ab..70dfad1409 100644 --- a/services/tasks/TaskPool.go +++ b/services/tasks/TaskPool.go @@ -1149,7 +1149,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 8988397fc9..0227583ef7 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'); }); }); From ac576fe6dd43ad74f55d1fb05b70909a21da9fef Mon Sep 17 00:00:00 2001 From: lduchosal Date: Sun, 4 Oct 2026 13:12:05 +0200 Subject: [PATCH 2/9] chore(docker): install subversion in server, runner and job images Subversion repositories need the svn binary (1.10 or later for --password-from-stdin) wherever repositories are checked out. --- deployment/docker/job/Dockerfile | 1 + deployment/docker/runner/Dockerfile | 2 +- deployment/docker/server/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) 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 d0ae46abc3..d6e748760e 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 && \ From 0b5f94e7b1c6ba1e42f0983414b2c8db44319fd7 Mon Sep 17 00:00:00 2001 From: lduchosal Date: Sun, 4 Oct 2026 13:19:53 +0200 Subject: [PATCH 3/9] fix(svn): compare working copy URLs the way svn canonicalizes them svn prints the URL of a working copy canonicalized: scheme and host in lower case, without the default svnserve port 3690, percent-encoded and without a trailing slash. Compared verbatim, svn://host:3690/repo never matched, and every task checked the repository out again instead of updating it. --- db_lib/SvnClient.go | 9 +++------ db_lib/SvnClient_test.go | 1 + pkg/svn/svn.go | 33 +++++++++++++++++++++++++++++++++ pkg/svn/svn_test.go | 24 ++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/db_lib/SvnClient.go b/db_lib/SvnClient.go index 8c72392dab..05a3593e0b 100644 --- a/db_lib/SvnClient.go +++ b/db_lib/SvnClient.go @@ -3,7 +3,6 @@ package db_lib import ( "encoding/xml" "fmt" - "net/url" "os" "os/exec" "strings" @@ -221,12 +220,10 @@ func (c SvnClient) CanBePulled(r GitRepository) bool { return false } - // svn prints the URL percent-encoded. + // 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") - if err != nil { - return false - } - if decoded, err := url.PathUnescape(wcURL); err != nil || decoded != branchURL { + if err != nil || !svn.SameURL(wcURL, branchURL) { return false } diff --git a/db_lib/SvnClient_test.go b/db_lib/SvnClient_test.go index faa106b39f..9d1ea9e462 100644 --- a/db_lib/SvnClient_test.go +++ b/db_lib/SvnClient_test.go @@ -340,6 +340,7 @@ func TestSvnClient_LoginPassword(t *testing.T) { 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) { diff --git a/pkg/svn/svn.go b/pkg/svn/svn.go index bcfc1b0a62..4e07b465ed 100644 --- a/pkg/svn/svn.go +++ b/pkg/svn/svn.go @@ -1,6 +1,7 @@ package svn import ( + "net/url" "regexp" "strings" @@ -33,3 +34,35 @@ func ValidateRevision(revision string, objectName string) error { 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 index 00adcf61dc..051677a734 100644 --- a/pkg/svn/svn_test.go +++ b/pkg/svn/svn_test.go @@ -51,3 +51,27 @@ func TestValidateRevision(t *testing.T) { }) } } + +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)) + }) + } +} From d3c280af33d17170ad8d3ab7c43459b606e171f2 Mon Sep 17 00:00:00 2001 From: lduchosal Date: Sun, 4 Oct 2026 13:33:59 +0200 Subject: [PATCH 4/9] fix(svn): run svn with a UTF-8 locale when none is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands get an environment without a locale. svn then wrote its translated messages in a legacy encoding, which the task log showed as replacement characters ("Mise � jour"), and on Linux it refuses to check out a file whose name is not ASCII. Set LC_CTYPE=C.UTF-8 unless LC_ALL, LC_CTYPE or LANG is configured. --- db_lib/SvnClient.go | 10 ++++++++++ db_lib/SvnClient_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/db_lib/SvnClient.go b/db_lib/SvnClient.go index 05a3593e0b..97ef1107fc 100644 --- a/db_lib/SvnClient.go +++ b/db_lib/SvnClient.go @@ -107,6 +107,16 @@ func (c SvnClient) makeCmd( } 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) diff --git a/db_lib/SvnClient_test.go b/db_lib/SvnClient_test.go index 9d1ea9e462..d6cab8ef9d 100644 --- a/db_lib/SvnClient_test.go +++ b/db_lib/SvnClient_test.go @@ -139,6 +139,41 @@ func TestSvnClient_CloneUpdateCheckout(t *testing.T) { 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) From aaeadb6fc789d953e7e3ada699841342254f8738 Mon Sep 17 00:00:00 2001 From: lduchosal Date: Sun, 4 Oct 2026 14:40:48 +0200 Subject: [PATCH 5/9] fix(svn): report what svn printed when a command fails A failed svn command returned its exit status alone, and the repository API, whose logger discards command output, logged "exit status 1" for every svn+ssh connection, path or credential problem. Add the end of svn's stderr to the error, with the userinfo of quoted URLs removed. --- db_lib/SvnClient.go | 60 +++++++++++++++++++++++++++++++++++++++- db_lib/SvnClient_test.go | 59 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/db_lib/SvnClient.go b/db_lib/SvnClient.go index 97ef1107fc..f7fa826d93 100644 --- a/db_lib/SvnClient.go +++ b/db_lib/SvnClient.go @@ -2,9 +2,12 @@ package db_lib import ( "encoding/xml" + "errors" "fmt" + "io" "os" "os/exec" + "regexp" "strings" "github.com/semaphoreui/semaphore/db" @@ -156,7 +159,16 @@ func (c SvnClient) run(r GitRepository, targetDir GitRepositoryDirType, args ... finishLog := r.Logger.LogCmd(cmd) defer finishLog() - return cmd.Run() + // 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) { @@ -169,12 +181,58 @@ func (c SvnClient) output(r GitRepository, targetDir GitRepositoryDirType, args 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()) diff --git a/db_lib/SvnClient_test.go b/db_lib/SvnClient_test.go index d6cab8ef9d..7d11ef2fbc 100644 --- a/db_lib/SvnClient_test.go +++ b/db_lib/SvnClient_test.go @@ -1,11 +1,13 @@ package db_lib import ( + "errors" "fmt" "net" "os" "os/exec" "path/filepath" + "strings" "testing" "time" @@ -416,6 +418,63 @@ func TestSvnClient_SshTunnel(t *testing.T) { } } +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) From 712ff9bef2fea7a4b07aeb447b20e0187b87ec0c Mon Sep 17 00:00:00 2001 From: lduchosal Date: Mon, 5 Oct 2026 10:59:42 +0200 Subject: [PATCH 6/9] test(svn): check out again a working copy with a scheduled addition svn revert would unschedule a scheduled addition and leave its file behind. CanBePulled sees it in svn status and the working copy of the template is checked out again instead; cover that case. --- db_lib/SvnClient_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/db_lib/SvnClient_test.go b/db_lib/SvnClient_test.go index 7d11ef2fbc..1c228b1fce 100644 --- a/db_lib/SvnClient_test.go +++ b/db_lib/SvnClient_test.go @@ -265,6 +265,17 @@ func TestSvnClient_CanBePulled(t *testing.T) { assert.False(t, client.CanBePulled(r)) }) + // svn revert would unschedule the addition but leave its file behind; the + // working copy is checked out again instead. + t.Run("scheduled addition", func(t *testing.T) { + r := newTestSvnRepo(t, f.url, "trunk") + r.TmpDirName = "added" + require.NoError(t, client.Clone(r)) + require.NoError(t, os.WriteFile(filepath.Join(r.GetFullPath(), "added.yml"), []byte("x"), 0644)) + svnRun(t, r.GetFullPath(), "add", "added.yml") + assert.False(t, client.CanBePulled(r)) + }) + t.Run("other branch", func(t *testing.T) { r := newTestSvnRepo(t, f.url, "trunk") r.TmpDirName = "other" From d89658559100a192bb75f3ad84c4ec850aea391e Mon Sep 17 00:00:00 2001 From: lduchosal Date: Mon, 5 Oct 2026 17:25:45 +0200 Subject: [PATCH 7/9] fix(svn): restore the working copy of a template before updating it svn status --quiet omits unversioned and ignored files, so a playbook or role a task generated in the working copy was kept by the update and could be picked up by the next task of the template. Pull now reverts local changes, then removes unversioned and ignored files with svn cleanup --remove-unversioned --remove-ignored, then updates. Reverting first also unschedules additions, whose files the cleanup then removes. Leftovers are removed in place instead of checking the working copy out again; a working copy svn cannot revert or clean makes Pull fail and is checked out again by the caller. Unlike git pull, untracked files are not kept. --- db_lib/SvnClient.go | 26 ++++++++++------ db_lib/SvnClient_test.go | 65 ++++++++++++++++++++++++---------------- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/db_lib/SvnClient.go b/db_lib/SvnClient.go index f7fa826d93..e63079ea3c 100644 --- a/db_lib/SvnClient.go +++ b/db_lib/SvnClient.go @@ -259,9 +259,22 @@ func (c SvnClient) Clone(r GitRepository) error { 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") } @@ -279,9 +292,9 @@ func (c SvnClient) Checkout(r GitRepository, target string) error { } // CanBePulled reports whether the working copy can be updated in place: it is -// a checkout of the same branch and has no local modifications. Unlike git -// pull, svn update merges into local modifications and reports a conflict as -// success, so a modified working copy is checked out again instead. +// 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 { @@ -291,12 +304,7 @@ func (c SvnClient) CanBePulled(r GitRepository) bool { // 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") - if err != nil || !svn.SameURL(wcURL, branchURL) { - return false - } - - status, err := c.output(r, GitRepositoryFullPath, "status", "--quiet") - return err == nil && status == "" + return err == nil && svn.SameURL(wcURL, branchURL) } type svnLog struct { diff --git a/db_lib/SvnClient_test.go b/db_lib/SvnClient_test.go index 1c228b1fce..b9a8af1dba 100644 --- a/db_lib/SvnClient_test.go +++ b/db_lib/SvnClient_test.go @@ -257,25 +257,6 @@ func TestSvnClient_CanBePulled(t *testing.T) { assert.False(t, client.CanBePulled(newTestSvnRepo(t, f.url, "trunk"))) }) - t.Run("local modification", func(t *testing.T) { - r := newTestSvnRepo(t, f.url, "trunk") - r.TmpDirName = "modified" - require.NoError(t, client.Clone(r)) - require.NoError(t, os.WriteFile(filepath.Join(r.GetFullPath(), "site.yml"), []byte("local"), 0644)) - assert.False(t, client.CanBePulled(r)) - }) - - // svn revert would unschedule the addition but leave its file behind; the - // working copy is checked out again instead. - t.Run("scheduled addition", func(t *testing.T) { - r := newTestSvnRepo(t, f.url, "trunk") - r.TmpDirName = "added" - require.NoError(t, client.Clone(r)) - require.NoError(t, os.WriteFile(filepath.Join(r.GetFullPath(), "added.yml"), []byte("x"), 0644)) - svnRun(t, r.GetFullPath(), "add", "added.yml") - assert.False(t, client.CanBePulled(r)) - }) - t.Run("other branch", func(t *testing.T) { r := newTestSvnRepo(t, f.url, "trunk") r.TmpDirName = "other" @@ -283,14 +264,46 @@ func TestSvnClient_CanBePulled(t *testing.T) { r.Repository.GitBranch = "branches/release" assert.False(t, client.CanBePulled(r)) }) +} - t.Run("unversioned file", func(t *testing.T) { - r := newTestSvnRepo(t, f.url, "trunk") - r.TmpDirName = "unversioned" - require.NoError(t, client.Clone(r)) - require.NoError(t, os.WriteFile(filepath.Join(r.GetFullPath(), "generated.retry"), []byte("x"), 0644)) - assert.True(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) } func TestSvnClient_RejectsInvalidBranch(t *testing.T) { From da7bb2603e2548a11c5cb19e24431496e31ddee4 Mon Sep 17 00:00:00 2001 From: lduchosal Date: Thu, 8 Oct 2026 23:40:35 +0200 Subject: [PATCH 8/9] test(svn): one working copy per template, as for git Nothing a task of one template does in its working copy, nor another template's update, reaches the working copy of another template. --- db_lib/SvnClient_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/db_lib/SvnClient_test.go b/db_lib/SvnClient_test.go index b9a8af1dba..4edb99d5d4 100644 --- a/db_lib/SvnClient_test.go +++ b/db_lib/SvnClient_test.go @@ -306,6 +306,33 @@ func TestSvnClient_PullRestoresWorkingCopy(t *testing.T) { 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") +} + func TestSvnClient_RejectsInvalidBranch(t *testing.T) { setupGitClientTest(t) f := newSvnFixture(t) From 3bf421e2a0098f915b3874eac82a3d6c730b1d69 Mon Sep 17 00:00:00 2001 From: lduchosal Date: Thu, 8 Oct 2026 23:41:15 +0200 Subject: [PATCH 9/9] test(svn): browse a branch through a scratch checkout, as for git The playbook picker checks the branch out in a scratch directory and lists it with FindRepositoryFiles, which skips the .svn directory as it skips .git. --- db_lib/SvnClient_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/db_lib/SvnClient_test.go b/db_lib/SvnClient_test.go index 4edb99d5d4..b8358ab895 100644 --- a/db_lib/SvnClient_test.go +++ b/db_lib/SvnClient_test.go @@ -333,6 +333,35 @@ func TestSvnClient_WorkingCopyPerTemplate(t *testing.T) { "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)