svn.py
269 lines
| 8.9 KiB
| text/x-python
|
PythonLexer
r5608 | # Copyright (C) 2016-2024 RhodeCode GmbH | |||
r2187 | # | |||
# This program is free software: you can redistribute it and/or modify | ||||
# it under the terms of the GNU Affero General Public License, version 3 | ||||
# (only), as published by the Free Software Foundation. | ||||
# | ||||
# This program is distributed in the hope that it will be useful, | ||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
# GNU General Public License for more details. | ||||
# | ||||
# You should have received a copy of the GNU Affero General Public License | ||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
# | ||||
# This program is dual-licensed. If you wish to learn more about the | ||||
# RhodeCode Enterprise Edition, including its added features, Support services, | ||||
# and proprietary license terms, please see https://rhodecode.com/licenses/ | ||||
import os | ||||
import re | ||||
import sys | ||||
import logging | ||||
import signal | ||||
import tempfile | ||||
from subprocess import Popen, PIPE | ||||
r4919 | import urllib.parse | |||
r2187 | ||||
r5603 | from rhodecode.lib.str_utils import safe_str | |||
r5329 | from .base import SshVcsServer | |||
r2187 | ||||
log = logging.getLogger(__name__) | ||||
class SubversionTunnelWrapper(object): | ||||
process = None | ||||
def __init__(self, server): | ||||
self.server = server | ||||
self.timeout = 30 | ||||
self.stdin = sys.stdin | ||||
self.stdout = sys.stdout | ||||
self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp() | ||||
self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp() | ||||
self.read_only = True # flag that we set to make the hooks readonly | ||||
def create_svn_config(self): | ||||
content = ( | ||||
'[general]\n' | ||||
'hooks-env = {}\n').format(self.hooks_env_path) | ||||
with os.fdopen(self.svn_conf_fd, 'w') as config_file: | ||||
config_file.write(content) | ||||
def create_hooks_env(self): | ||||
content = ( | ||||
'[default]\n' | ||||
'LANG = en_US.UTF-8\n') | ||||
if self.read_only: | ||||
content += 'SSH_READ_ONLY = 1\n' | ||||
with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file: | ||||
hooks_env_file.write(content) | ||||
def remove_configs(self): | ||||
os.remove(self.svn_conf_path) | ||||
os.remove(self.hooks_env_path) | ||||
def command(self): | ||||
root = self.server.get_root_store() | ||||
r4286 | username = self.server.user.username | |||
r2187 | command = [ | |||
self.server.svn_path, '-t', | ||||
'--config-file', self.svn_conf_path, | ||||
r4286 | '--tunnel-user', username, | |||
r2187 | '-r', root] | |||
r2595 | log.debug("Final CMD: %s", ' '.join(command)) | |||
r2187 | return command | |||
def start(self): | ||||
command = self.command() | ||||
r2595 | self.process = Popen(' '.join(command), stdin=PIPE, shell=True) | |||
r2187 | ||||
def sync(self): | ||||
while self.process.poll() is None: | ||||
r5330 | next_byte = self.stdin.buffer.read(1) | |||
r2187 | if not next_byte: | |||
break | ||||
self.process.stdin.write(next_byte) | ||||
self.remove_configs() | ||||
@property | ||||
def return_code(self): | ||||
return self.process.returncode | ||||
def get_first_client_response(self): | ||||
signal.signal(signal.SIGALRM, self.interrupt) | ||||
signal.alarm(self.timeout) | ||||
first_response = self._read_first_client_response() | ||||
signal.alarm(0) | ||||
r4281 | return (self._parse_first_client_response(first_response) | |||
if first_response else None) | ||||
r2187 | ||||
def patch_first_client_response(self, response, **kwargs): | ||||
self.create_hooks_env() | ||||
r5333 | ||||
version = response['version'] | ||||
capabilities = response['capabilities'] | ||||
client = response['client'] or b'' | ||||
url = self._svn_bytes(response['url']) | ||||
ra_client = self._svn_bytes(response['ra_client']) | ||||
buffer_ = b"( %b ( %b ) %b%b( %b) ) " % ( | ||||
version, | ||||
capabilities, | ||||
url, | ||||
ra_client, | ||||
client | ||||
) | ||||
r2187 | self.process.stdin.write(buffer_) | |||
def fail(self, message): | ||||
r5333 | fail_msg = b"( failure ( ( 210005 %b 0: 0 ) ) )" % self._svn_bytes(message) | |||
sys.stdout.buffer.write(fail_msg) | ||||
sys.stdout.flush() | ||||
r2187 | self.remove_configs() | |||
self.process.kill() | ||||
r2603 | return 1 | |||
r2187 | ||||
def interrupt(self, signum, frame): | ||||
self.fail("Exited by timeout") | ||||
r5333 | def _svn_bytes(self, bytes_: bytes) -> bytes: | |||
if not bytes_: | ||||
return b'' | ||||
return f'{len(bytes_)}:'.encode() + bytes_ + b' ' | ||||
r2187 | ||||
def _read_first_client_response(self): | ||||
r5330 | buffer_ = b"" | |||
r2187 | brackets_stack = [] | |||
while True: | ||||
r5330 | next_byte = self.stdin.buffer.read(1) | |||
r2187 | buffer_ += next_byte | |||
r5330 | if next_byte == b"(": | |||
r2187 | brackets_stack.append(next_byte) | |||
r5330 | elif next_byte == b")": | |||
r2187 | brackets_stack.pop() | |||
r5330 | elif next_byte == b" " and not brackets_stack: | |||
r2187 | break | |||
r4281 | ||||
r2187 | return buffer_ | |||
r5330 | def _parse_first_client_response(self, buffer_: bytes): | |||
r2187 | """ | |||
According to the Subversion RA protocol, the first request | ||||
should look like: | ||||
( version:number ( cap:word ... ) url:string ? ra-client:string | ||||
( ? client:string ) ) | ||||
r4281 | Please check https://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol | |||
r2187 | """ | |||
r5330 | version_re = br'(?P<version>\d+)' | |||
capabilities_re = br'\(\s(?P<capabilities>[\w\d\-\ ]+)\s\)' | ||||
url_re = br'\d+\:(?P<url>[\W\w]+)' | ||||
ra_client_re = br'(\d+\:(?P<ra_client>[\W\w]+)\s)' | ||||
client_re = br'(\d+\:(?P<client>[\W\w]+)\s)*' | ||||
r2187 | regex = re.compile( | |||
r5330 | br'^\(\s%b\s%b\s%b\s%b' | |||
br'\(\s%b\)\s\)\s*$' % ( | ||||
version_re, | ||||
capabilities_re, | ||||
url_re, | ||||
ra_client_re, | ||||
client_re) | ||||
) | ||||
r2187 | matcher = regex.match(buffer_) | |||
r4281 | ||||
r2187 | return matcher.groupdict() if matcher else None | |||
r4281 | def _match_repo_name(self, url): | |||
""" | ||||
Given an server url, try to match it against ALL known repository names. | ||||
This handles a tricky SVN case for SSH and subdir commits. | ||||
E.g if our repo name is my-svn-repo, a svn commit on file in a subdir would | ||||
result in the url with this subdir added. | ||||
""" | ||||
# case 1 direct match, we don't do any "heavy" lookups | ||||
if url in self.server.user_permissions: | ||||
return url | ||||
log.debug('Extracting repository name from subdir path %s', url) | ||||
# case 2 we check all permissions, and match closes possible case... | ||||
# NOTE(dan): In this case we only know that url has a subdir parts, it's safe | ||||
# to assume that it will have the repo name as prefix, we ensure the prefix | ||||
# for similar repositories isn't matched by adding a / | ||||
# e.g subgroup/repo-name/ and subgroup/repo-name-1/ would work correct. | ||||
for repo_name in self.server.user_permissions: | ||||
repo_name_prefix = repo_name + '/' | ||||
if url.startswith(repo_name_prefix): | ||||
log.debug('Found prefix %s match, returning proper repository name', | ||||
repo_name_prefix) | ||||
return repo_name | ||||
return | ||||
r2187 | def run(self, extras): | |||
action = 'pull' | ||||
self.create_svn_config() | ||||
self.start() | ||||
first_response = self.get_first_client_response() | ||||
if not first_response: | ||||
r5336 | return self.fail(b"Repository name cannot be extracted") | |||
r2187 | ||||
r4950 | url_parts = urllib.parse.urlparse(first_response['url']) | |||
r4281 | ||||
r5330 | self.server.repo_name = self._match_repo_name(safe_str(url_parts.path).strip('/')) | |||
r2187 | ||||
exit_code = self.server._check_permissions(action) | ||||
if exit_code: | ||||
return exit_code | ||||
# set the readonly flag to False if we have proper permissions | ||||
if self.server.has_write_perm(): | ||||
self.read_only = False | ||||
self.server.update_environment(action=action, extras=extras) | ||||
self.patch_first_client_response(first_response) | ||||
self.sync() | ||||
return self.return_code | ||||
r5329 | class SubversionServer(SshVcsServer): | |||
r2187 | backend = 'svn' | |||
r4858 | repo_user_agent = 'svn' | |||
r2187 | ||||
r5329 | def __init__(self, store, ini_path, repo_name, user, user_permissions, settings, env): | |||
super().__init__(user, user_permissions, settings, env) | ||||
r2187 | self.store = store | |||
self.ini_path = ini_path | ||||
r4281 | # NOTE(dan): repo_name at this point is empty, | |||
# this is set later in .run() based from parsed input stream | ||||
r2187 | self.repo_name = repo_name | |||
r5329 | self._path = self.svn_path = settings['ssh.executable.svn'] | |||
r2187 | ||||
self.tunnel = SubversionTunnelWrapper(server=self) | ||||
def _handle_tunnel(self, extras): | ||||
# pre-auth | ||||
action = 'pull' | ||||
# Special case for SVN, we extract repo name at later stage | ||||
# exit_code = self._check_permissions(action) | ||||
# if exit_code: | ||||
# return exit_code, False | ||||
r5328 | req = self.env.get('request') | |||
if req: | ||||
server_url = req.host_url + req.script_name | ||||
extras['server_url'] = server_url | ||||
r2187 | ||||
log.debug('Using %s binaries from path %s', self.backend, self._path) | ||||
exit_code = self.tunnel.run(extras) | ||||
return exit_code, action == "push" | ||||