|
|
# Copyright (C) 2016-2023 RhodeCode GmbH
|
|
|
#
|
|
|
# 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
|
|
|
import urllib.parse
|
|
|
|
|
|
from rhodecode_tools.lib.utils import safe_str
|
|
|
from .base import SshVcsServer
|
|
|
|
|
|
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()
|
|
|
username = self.server.user.username
|
|
|
|
|
|
command = [
|
|
|
self.server.svn_path, '-t',
|
|
|
'--config-file', self.svn_conf_path,
|
|
|
'--tunnel-user', username,
|
|
|
'-r', root]
|
|
|
log.debug("Final CMD: %s", ' '.join(command))
|
|
|
return command
|
|
|
|
|
|
def start(self):
|
|
|
command = self.command()
|
|
|
self.process = Popen(' '.join(command), stdin=PIPE, shell=True)
|
|
|
|
|
|
def sync(self):
|
|
|
while self.process.poll() is None:
|
|
|
next_byte = self.stdin.buffer.read(1)
|
|
|
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)
|
|
|
return (self._parse_first_client_response(first_response)
|
|
|
if first_response else None)
|
|
|
|
|
|
def patch_first_client_response(self, response, **kwargs):
|
|
|
self.create_hooks_env()
|
|
|
|
|
|
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
|
|
|
)
|
|
|
self.process.stdin.write(buffer_)
|
|
|
|
|
|
def fail(self, message):
|
|
|
fail_msg = b"( failure ( ( 210005 %b 0: 0 ) ) )" % self._svn_bytes(message)
|
|
|
sys.stdout.buffer.write(fail_msg)
|
|
|
sys.stdout.flush()
|
|
|
self.remove_configs()
|
|
|
self.process.kill()
|
|
|
return 1
|
|
|
|
|
|
def interrupt(self, signum, frame):
|
|
|
self.fail("Exited by timeout")
|
|
|
|
|
|
def _svn_bytes(self, bytes_: bytes) -> bytes:
|
|
|
if not bytes_:
|
|
|
return b''
|
|
|
|
|
|
return f'{len(bytes_)}:'.encode() + bytes_ + b' '
|
|
|
|
|
|
def _read_first_client_response(self):
|
|
|
buffer_ = b""
|
|
|
brackets_stack = []
|
|
|
while True:
|
|
|
next_byte = self.stdin.buffer.read(1)
|
|
|
buffer_ += next_byte
|
|
|
if next_byte == b"(":
|
|
|
brackets_stack.append(next_byte)
|
|
|
elif next_byte == b")":
|
|
|
brackets_stack.pop()
|
|
|
elif next_byte == b" " and not brackets_stack:
|
|
|
break
|
|
|
|
|
|
return buffer_
|
|
|
|
|
|
def _parse_first_client_response(self, buffer_: bytes):
|
|
|
"""
|
|
|
According to the Subversion RA protocol, the first request
|
|
|
should look like:
|
|
|
|
|
|
( version:number ( cap:word ... ) url:string ? ra-client:string
|
|
|
( ? client:string ) )
|
|
|
|
|
|
Please check https://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol
|
|
|
"""
|
|
|
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)*'
|
|
|
regex = re.compile(
|
|
|
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)
|
|
|
)
|
|
|
matcher = regex.match(buffer_)
|
|
|
|
|
|
return matcher.groupdict() if matcher else None
|
|
|
|
|
|
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
|
|
|
|
|
|
def run(self, extras):
|
|
|
action = 'pull'
|
|
|
self.create_svn_config()
|
|
|
self.start()
|
|
|
|
|
|
first_response = self.get_first_client_response()
|
|
|
if not first_response:
|
|
|
return self.fail(b"Repository name cannot be extracted")
|
|
|
|
|
|
url_parts = urllib.parse.urlparse(first_response['url'])
|
|
|
|
|
|
self.server.repo_name = self._match_repo_name(safe_str(url_parts.path).strip('/'))
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
class SubversionServer(SshVcsServer):
|
|
|
backend = 'svn'
|
|
|
repo_user_agent = 'svn'
|
|
|
|
|
|
def __init__(self, store, ini_path, repo_name, user, user_permissions, settings, env):
|
|
|
super().__init__(user, user_permissions, settings, env)
|
|
|
self.store = store
|
|
|
self.ini_path = ini_path
|
|
|
# NOTE(dan): repo_name at this point is empty,
|
|
|
# this is set later in .run() based from parsed input stream
|
|
|
self.repo_name = repo_name
|
|
|
self._path = self.svn_path = settings['ssh.executable.svn']
|
|
|
|
|
|
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
|
|
|
|
|
|
req = self.env.get('request')
|
|
|
if req:
|
|
|
server_url = req.host_url + req.script_name
|
|
|
extras['server_url'] = server_url
|
|
|
|
|
|
log.debug('Using %s binaries from path %s', self.backend, self._path)
|
|
|
exit_code = self.tunnel.run(extras)
|
|
|
|
|
|
return exit_code, action == "push"
|
|
|
|
|
|
|
|
|
|