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