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