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