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