unrhodecode/rhodecode/apps/ssh_support/lib/backends/svn.py
2025-12-03 13:39:14 +01:00

271 lines
9.6 KiB
Python

# Copyright (C) 2016-2024 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.lib.str_utils import safe_str, safe_bytes
from rhodecode.lib.ext_json import sjson as json
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.buffer
self.stdout = sys.stdout.buffer
self.root_store = self.server.get_root_store()
self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp(dir=self.root_store, prefix="svn_conf_")
self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp(dir=self.root_store, prefix="svn_hooks_env_")
self.read_only = True # flag that we set to make the hooks readonly
def create_svn_config(self):
content = f"[general]\nhooks-env = {self.hooks_env_path}\n"
with os.fdopen(self.svn_conf_fd, "w") as config_file:
config_file.write(content)
def create_hooks_env(self, extra_data=""):
content = "[default]\nLANG = en_US.UTF-8\n"
if self.read_only:
content += "SSH_READ_ONLY = 1\n"
if extra_data:
content += extra_data
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.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.read(1)
if not next_byte:
break
try:
self.process.stdin.write(next_byte)
self.process.stdin.flush() # Ensure data is sent immediately
except BrokenPipeError:
# Handle cases where the subprocess closes stdin unexpectedly
break
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 _prepare_first_client_response(self, response: dict, **kwargs) -> bytes:
version = response["version"]
capabilities = response["capabilities"]
url = self._svn_bytes(response["url"])
ra_client = self._svn_bytes(response["ra_client"])
client = b" " + self._svn_bytes(response["client"]) or b""
buffer_ = b"( %b ( %b ) %b%b(%b) )%b" % (version, capabilities, url, ra_client, client, b" ")
return buffer_
def patch_first_client_response(self, response, **kwargs):
buffer_ = self._prepare_first_client_response(response, **kwargs)
self.process.stdin.write(buffer_)
self.process.stdin.flush()
def fail(self, message):
fail_msg = b"( failure ( ( 210005 %b 0: 0 ) ) )" % self._svn_bytes(message)
self.stdout.write(fail_msg)
self.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""
if not isinstance(bytes_, bytes):
bytes_ = safe_bytes(bytes_)
return f"{len(bytes_)}:".encode() + bytes_ + b" "
def _read_first_client_response(self):
buffer_ = b""
brackets_stack = []
while True:
next_byte = self.stdin.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 = rb"(?P<version>\d+)"
capabilities_re = rb"\(\s(?P<capabilities>[\w\d\-\ ]+)\s\)"
url_re = rb"\d+\:(?P<url>[\W\w]+)"
ra_client_re = rb"(\d+\:(?P<ra_client>[\W\w]+)\s)"
client_re = rb"(\d+\:(?P<client>[\W\w]+)\s)*"
regex = re.compile(
rb"^\(\s%b\s%b\s%b\s%b\(\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 a 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
scm_data = self.server.update_environment(action=action, extras=extras)
scm_data_str = f"RC_SCM_DATA={json.dumps(scm_data)}"
self.create_hooks_env(scm_data_str)
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"