##// END OF EJS Templates
svn: explicitly specify tunnel-user to properly map rhodecode username on svn commit via SSH backend...
dan -
r4286:c8bb3cfc default
parent child Browse files
Show More
@@ -1,254 +1,257 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import os
22 22 import re
23 23 import sys
24 24 import logging
25 25 import signal
26 26 import tempfile
27 27 from subprocess import Popen, PIPE
28 28 import urlparse
29 29
30 30 from .base import VcsServer
31 31
32 32 log = logging.getLogger(__name__)
33 33
34 34
35 35 class SubversionTunnelWrapper(object):
36 36 process = None
37 37
38 38 def __init__(self, server):
39 39 self.server = server
40 40 self.timeout = 30
41 41 self.stdin = sys.stdin
42 42 self.stdout = sys.stdout
43 43 self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp()
44 44 self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp()
45 45
46 46 self.read_only = True # flag that we set to make the hooks readonly
47 47
48 48 def create_svn_config(self):
49 49 content = (
50 50 '[general]\n'
51 51 'hooks-env = {}\n').format(self.hooks_env_path)
52 52 with os.fdopen(self.svn_conf_fd, 'w') as config_file:
53 53 config_file.write(content)
54 54
55 55 def create_hooks_env(self):
56 56 content = (
57 57 '[default]\n'
58 58 'LANG = en_US.UTF-8\n')
59 59 if self.read_only:
60 60 content += 'SSH_READ_ONLY = 1\n'
61 61 with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file:
62 62 hooks_env_file.write(content)
63 63
64 64 def remove_configs(self):
65 65 os.remove(self.svn_conf_path)
66 66 os.remove(self.hooks_env_path)
67 67
68 68 def command(self):
69 69 root = self.server.get_root_store()
70 username = self.server.user.username
71
70 72 command = [
71 73 self.server.svn_path, '-t',
72 74 '--config-file', self.svn_conf_path,
75 '--tunnel-user', username,
73 76 '-r', root]
74 77 log.debug("Final CMD: %s", ' '.join(command))
75 78 return command
76 79
77 80 def start(self):
78 81 command = self.command()
79 82 self.process = Popen(' '.join(command), stdin=PIPE, shell=True)
80 83
81 84 def sync(self):
82 85 while self.process.poll() is None:
83 86 next_byte = self.stdin.read(1)
84 87 if not next_byte:
85 88 break
86 89 self.process.stdin.write(next_byte)
87 90 self.remove_configs()
88 91
89 92 @property
90 93 def return_code(self):
91 94 return self.process.returncode
92 95
93 96 def get_first_client_response(self):
94 97 signal.signal(signal.SIGALRM, self.interrupt)
95 98 signal.alarm(self.timeout)
96 99 first_response = self._read_first_client_response()
97 100 signal.alarm(0)
98 101 return (self._parse_first_client_response(first_response)
99 102 if first_response else None)
100 103
101 104 def patch_first_client_response(self, response, **kwargs):
102 105 self.create_hooks_env()
103 106 data = response.copy()
104 107 data.update(kwargs)
105 108 data['url'] = self._svn_string(data['url'])
106 109 data['ra_client'] = self._svn_string(data['ra_client'])
107 110 data['client'] = data['client'] or ''
108 111 buffer_ = (
109 112 "( {version} ( {capabilities} ) {url}{ra_client}"
110 113 "( {client}) ) ".format(**data))
111 114 self.process.stdin.write(buffer_)
112 115
113 116 def fail(self, message):
114 117 print("( failure ( ( 210005 {message} 0: 0 ) ) )".format(
115 118 message=self._svn_string(message)))
116 119 self.remove_configs()
117 120 self.process.kill()
118 121 return 1
119 122
120 123 def interrupt(self, signum, frame):
121 124 self.fail("Exited by timeout")
122 125
123 126 def _svn_string(self, str_):
124 127 if not str_:
125 128 return ''
126 129 return '{length}:{string} '.format(length=len(str_), string=str_)
127 130
128 131 def _read_first_client_response(self):
129 132 buffer_ = ""
130 133 brackets_stack = []
131 134 while True:
132 135 next_byte = self.stdin.read(1)
133 136 buffer_ += next_byte
134 137 if next_byte == "(":
135 138 brackets_stack.append(next_byte)
136 139 elif next_byte == ")":
137 140 brackets_stack.pop()
138 141 elif next_byte == " " and not brackets_stack:
139 142 break
140 143
141 144 return buffer_
142 145
143 146 def _parse_first_client_response(self, buffer_):
144 147 """
145 148 According to the Subversion RA protocol, the first request
146 149 should look like:
147 150
148 151 ( version:number ( cap:word ... ) url:string ? ra-client:string
149 152 ( ? client:string ) )
150 153
151 154 Please check https://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol
152 155 """
153 156 version_re = r'(?P<version>\d+)'
154 157 capabilities_re = r'\(\s(?P<capabilities>[\w\d\-\ ]+)\s\)'
155 158 url_re = r'\d+\:(?P<url>[\W\w]+)'
156 159 ra_client_re = r'(\d+\:(?P<ra_client>[\W\w]+)\s)'
157 160 client_re = r'(\d+\:(?P<client>[\W\w]+)\s)*'
158 161 regex = re.compile(
159 162 r'^\(\s{version}\s{capabilities}\s{url}\s{ra_client}'
160 163 r'\(\s{client}\)\s\)\s*$'.format(
161 164 version=version_re, capabilities=capabilities_re,
162 165 url=url_re, ra_client=ra_client_re, client=client_re))
163 166 matcher = regex.match(buffer_)
164 167
165 168 return matcher.groupdict() if matcher else None
166 169
167 170 def _match_repo_name(self, url):
168 171 """
169 172 Given an server url, try to match it against ALL known repository names.
170 173 This handles a tricky SVN case for SSH and subdir commits.
171 174 E.g if our repo name is my-svn-repo, a svn commit on file in a subdir would
172 175 result in the url with this subdir added.
173 176 """
174 177 # case 1 direct match, we don't do any "heavy" lookups
175 178 if url in self.server.user_permissions:
176 179 return url
177 180
178 181 log.debug('Extracting repository name from subdir path %s', url)
179 182 # case 2 we check all permissions, and match closes possible case...
180 183 # NOTE(dan): In this case we only know that url has a subdir parts, it's safe
181 184 # to assume that it will have the repo name as prefix, we ensure the prefix
182 185 # for similar repositories isn't matched by adding a /
183 186 # e.g subgroup/repo-name/ and subgroup/repo-name-1/ would work correct.
184 187 for repo_name in self.server.user_permissions:
185 188 repo_name_prefix = repo_name + '/'
186 189 if url.startswith(repo_name_prefix):
187 190 log.debug('Found prefix %s match, returning proper repository name',
188 191 repo_name_prefix)
189 192 return repo_name
190 193
191 194 return
192 195
193 196 def run(self, extras):
194 197 action = 'pull'
195 198 self.create_svn_config()
196 199 self.start()
197 200
198 201 first_response = self.get_first_client_response()
199 202 if not first_response:
200 203 return self.fail("Repository name cannot be extracted")
201 204
202 205 url_parts = urlparse.urlparse(first_response['url'])
203 206
204 207 self.server.repo_name = self._match_repo_name(url_parts.path.strip('/'))
205 208
206 209 exit_code = self.server._check_permissions(action)
207 210 if exit_code:
208 211 return exit_code
209 212
210 213 # set the readonly flag to False if we have proper permissions
211 214 if self.server.has_write_perm():
212 215 self.read_only = False
213 216 self.server.update_environment(action=action, extras=extras)
214 217
215 218 self.patch_first_client_response(first_response)
216 219 self.sync()
217 220 return self.return_code
218 221
219 222
220 223 class SubversionServer(VcsServer):
221 224 backend = 'svn'
222 225
223 226 def __init__(self, store, ini_path, repo_name,
224 227 user, user_permissions, config, env):
225 228 super(SubversionServer, self)\
226 229 .__init__(user, user_permissions, config, env)
227 230 self.store = store
228 231 self.ini_path = ini_path
229 232 # NOTE(dan): repo_name at this point is empty,
230 233 # this is set later in .run() based from parsed input stream
231 234 self.repo_name = repo_name
232 235 self._path = self.svn_path = config.get('app:main', 'ssh.executable.svn')
233 236
234 237 self.tunnel = SubversionTunnelWrapper(server=self)
235 238
236 239 def _handle_tunnel(self, extras):
237 240
238 241 # pre-auth
239 242 action = 'pull'
240 243 # Special case for SVN, we extract repo name at later stage
241 244 # exit_code = self._check_permissions(action)
242 245 # if exit_code:
243 246 # return exit_code, False
244 247
245 248 req = self.env['request']
246 249 server_url = req.host_url + req.script_name
247 250 extras['server_url'] = server_url
248 251
249 252 log.debug('Using %s binaries from path %s', self.backend, self._path)
250 253 exit_code = self.tunnel.run(extras)
251 254
252 255 return exit_code, action == "push"
253 256
254 257
@@ -1,204 +1,206 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23
24 24 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionServer
25 25 from rhodecode.apps.ssh_support.tests.conftest import plain_dummy_env, plain_dummy_user
26 26
27 27
28 28 class SubversionServerCreator(object):
29 29 root = '/tmp/repo/path/'
30 30 svn_path = '/usr/local/bin/svnserve'
31 31 config_data = {
32 32 'app:main': {
33 33 'ssh.executable.svn': svn_path,
34 34 'vcs.hooks.protocol': 'http',
35 35 }
36 36 }
37 37 repo_name = 'test-svn'
38 38 user = plain_dummy_user()
39 39
40 40 def __init__(self):
41 41 def config_get(part, key):
42 42 return self.config_data.get(part, {}).get(key)
43 43 self.config_mock = mock.Mock()
44 44 self.config_mock.get = mock.Mock(side_effect=config_get)
45 45
46 46 def create(self, **kwargs):
47 47 parameters = {
48 48 'store': self.root,
49 49 'repo_name': self.repo_name,
50 50 'ini_path': '',
51 51 'user': self.user,
52 52 'user_permissions': {
53 53 self.repo_name: 'repository.admin'
54 54 },
55 55 'config': self.config_mock,
56 56 'env': plain_dummy_env()
57 57 }
58 58
59 59 parameters.update(kwargs)
60 60 server = SubversionServer(**parameters)
61 61 return server
62 62
63 63
64 64 @pytest.fixture()
65 65 def svn_server(app):
66 66 return SubversionServerCreator()
67 67
68 68
69 69 class TestSubversionServer(object):
70 70 def test_command(self, svn_server):
71 71 server = svn_server.create()
72 72 expected_command = [
73 svn_server.svn_path, '-t', '--config-file',
74 server.tunnel.svn_conf_path, '-r', svn_server.root
73 svn_server.svn_path, '-t',
74 '--config-file', server.tunnel.svn_conf_path,
75 '--tunnel-user', svn_server.user.username,
76 '-r', svn_server.root
75 77 ]
76 78
77 79 assert expected_command == server.tunnel.command()
78 80
79 81 @pytest.mark.parametrize('permissions, action, code', [
80 82 ({}, 'pull', -2),
81 83 ({'test-svn': 'repository.read'}, 'pull', 0),
82 84 ({'test-svn': 'repository.read'}, 'push', -2),
83 85 ({'test-svn': 'repository.write'}, 'push', 0),
84 86 ({'test-svn': 'repository.admin'}, 'push', 0),
85 87
86 88 ])
87 89 def test_permission_checks(self, svn_server, permissions, action, code):
88 90 server = svn_server.create(user_permissions=permissions)
89 91 result = server._check_permissions(action)
90 92 assert result is code
91 93
92 94 @pytest.mark.parametrize('permissions, access_paths, expected_match', [
93 95 # not matched repository name
94 96 ({
95 97 'test-svn': ''
96 98 }, ['test-svn-1', 'test-svn-1/subpath'],
97 99 None),
98 100
99 101 # exact match
100 102 ({
101 103 'test-svn': ''
102 104 },
103 105 ['test-svn'],
104 106 'test-svn'),
105 107
106 108 # subdir commits
107 109 ({
108 110 'test-svn': ''
109 111 },
110 112 ['test-svn/foo',
111 113 'test-svn/foo/test-svn',
112 114 'test-svn/trunk/development.txt',
113 115 ],
114 116 'test-svn'),
115 117
116 118 # subgroups + similar patterns
117 119 ({
118 120 'test-svn': '',
119 121 'test-svn-1': '',
120 122 'test-svn-subgroup/test-svn': '',
121 123
122 124 },
123 125 ['test-svn-1',
124 126 'test-svn-1/foo/test-svn',
125 127 'test-svn-1/test-svn',
126 128 ],
127 129 'test-svn-1'),
128 130
129 131 # subgroups + similar patterns
130 132 ({
131 133 'test-svn-1': '',
132 134 'test-svn-10': '',
133 135 'test-svn-100': '',
134 136 },
135 137 ['test-svn-10',
136 138 'test-svn-10/foo/test-svn',
137 139 'test-svn-10/test-svn',
138 140 ],
139 141 'test-svn-10'),
140 142
141 143 # subgroups + similar patterns
142 144 ({
143 145 'name': '',
144 146 'nameContains': '',
145 147 'nameContainsThis': '',
146 148 },
147 149 ['nameContains',
148 150 'nameContains/This',
149 151 'nameContains/This/test-svn',
150 152 ],
151 153 'nameContains'),
152 154
153 155 # subgroups + similar patterns
154 156 ({
155 157 'test-svn': '',
156 158 'test-svn-1': '',
157 159 'test-svn-subgroup/test-svn': '',
158 160
159 161 },
160 162 ['test-svn-subgroup/test-svn',
161 163 'test-svn-subgroup/test-svn/foo/test-svn',
162 164 'test-svn-subgroup/test-svn/trunk/example.txt',
163 165 ],
164 166 'test-svn-subgroup/test-svn'),
165 167 ])
166 168 def test_repo_extraction_on_subdir(self, svn_server, permissions, access_paths, expected_match):
167 169 server = svn_server.create(user_permissions=permissions)
168 170 for path in access_paths:
169 171 repo_name = server.tunnel._match_repo_name(path)
170 172 assert repo_name == expected_match
171 173
172 174 def test_run_returns_executes_command(self, svn_server):
173 175 server = svn_server.create()
174 176 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionTunnelWrapper
175 177 with mock.patch.object(
176 178 SubversionTunnelWrapper, 'get_first_client_response',
177 179 return_value={'url': 'http://server/test-svn'}):
178 180 with mock.patch.object(
179 181 SubversionTunnelWrapper, 'patch_first_client_response',
180 182 return_value=0):
181 183 with mock.patch.object(
182 184 SubversionTunnelWrapper, 'sync',
183 185 return_value=0):
184 186 with mock.patch.object(
185 187 SubversionTunnelWrapper, 'command',
186 188 return_value=['date']):
187 189
188 190 exit_code = server.run()
189 191 # SVN has this differently configured, and we get in our mock env
190 192 # None as return code
191 193 assert exit_code == (None, False)
192 194
193 195 def test_run_returns_executes_command_that_cannot_extract_repo_name(self, svn_server):
194 196 server = svn_server.create()
195 197 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionTunnelWrapper
196 198 with mock.patch.object(
197 199 SubversionTunnelWrapper, 'command',
198 200 return_value=['date']):
199 201 with mock.patch.object(
200 202 SubversionTunnelWrapper, 'get_first_client_response',
201 203 return_value=None):
202 204 exit_code = server.run()
203 205
204 206 assert exit_code == (1, False)
General Comments 0
You need to be logged in to leave comments. Login now