diff --git a/swh/loader/svn/loader.py b/swh/loader/svn/loader.py --- a/swh/loader/svn/loader.py +++ b/swh/loader/svn/loader.py @@ -671,6 +671,14 @@ """ # Build the svnrdump command line svnrdump_cmd = ["svnrdump", "dump", svn_url] + assert self.svnrepo is not None + if self.svnrepo.username: + svnrdump_cmd += [ + "--username", + self.svnrepo.username, + "--password", + self.svnrepo.password, + ] # Launch the svnrdump command while capturing stderr as # successfully dumped revision numbers are printed to it diff --git a/swh/loader/svn/svn.py b/swh/loader/svn/svn.py --- a/swh/loader/svn/svn.py +++ b/swh/loader/svn/svn.py @@ -13,9 +13,15 @@ import shutil import tempfile from typing import Dict, Iterator, List, Optional, Tuple, Union +from urllib.parse import urlparse, urlunparse from subvertpy import SubversionException, client, properties, wc -from subvertpy.ra import Auth, RemoteAccess, get_username_provider +from subvertpy.ra import ( + Auth, + RemoteAccess, + get_simple_prompt_provider, + get_username_provider, +) from swh.model.from_disk import Directory as DirectoryFromDisk from swh.model.model import ( @@ -54,11 +60,42 @@ max_content_length: int, from_dump: bool = False, ): - self.remote_url = remote_url.rstrip("/") self.origin_url = origin_url self.from_dump = from_dump - auth = Auth([get_username_provider()]) + # default auth provider for anonymous access + auth_providers = [get_username_provider()] + + # check if basic auth is required + parsed_origin_url = urlparse(origin_url) + self.username = parsed_origin_url.username or "" + self.password = parsed_origin_url.password or "" + if self.username: + # add basic auth provider for username/password + auth_providers.append( + get_simple_prompt_provider( + lambda realm, uname, may_save: ( + self.username, + self.password, + False, + ), + 0, + ) + ) + + # we need to remove the authentication part in the origin URL to avoid + # errors when calling subversion API through subvertpy + self.origin_url = urlunparse( + parsed_origin_url._replace( + netloc=parsed_origin_url.netloc.split("@", 1)[1] + ) + ) + if origin_url == remote_url: + remote_url = self.origin_url + + self.remote_url = remote_url.rstrip("/") + + auth = Auth(auth_providers) # one client for update operation self.client = client.Client(auth=auth) @@ -89,8 +126,8 @@ # compute root directory path from the remote repository URL, required to # properly load the sub-tree of a repository mounted from a dump file - repos_root_url = self.info(origin_url).repos_root_url - self.root_directory = origin_url.rstrip("/").replace(repos_root_url, "", 1) + repos_root_url = self.info(self.origin_url).repos_root_url + self.root_directory = self.origin_url.rstrip("/").replace(repos_root_url, "", 1) def __str__(self): return str( diff --git a/swh/loader/svn/tests/conftest.py b/swh/loader/svn/tests/conftest.py --- a/swh/loader/svn/tests/conftest.py +++ b/swh/loader/svn/tests/conftest.py @@ -3,6 +3,7 @@ # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information +import subprocess from typing import Any, Dict import pytest @@ -57,3 +58,28 @@ mocker.patch.object(SvnRepo.remote_access.retry, "sleep") mocker.patch.object(SvnRepo.info.retry, "sleep") mocker.patch.object(SvnRepo.commit_info.retry, "sleep") + + +@pytest.fixture +def svnserve(): + """Fixture wrapping svnserve execution and ensuring to terminate it + after test run""" + svnserve_proc = None + + def run_svnserve(repo_root, port): + nonlocal svnserve_proc + svnserve_proc = subprocess.Popen( + [ + "svnserve", + "-d", + "--foreground", + "--listen-port", + str(port), + "--root", + repo_root, + ] + ) + + yield run_svnserve + + svnserve_proc.terminate() diff --git a/swh/loader/svn/tests/test_loader.py b/swh/loader/svn/tests/test_loader.py --- a/swh/loader/svn/tests/test_loader.py +++ b/swh/loader/svn/tests/test_loader.py @@ -6,6 +6,7 @@ import os import shutil import subprocess +import textwrap from typing import Any, Dict import pytest @@ -2099,3 +2100,75 @@ # check redirection URL has been used to dump repository assert loader.dump_svn_revisions.call_args_list[0][0][0] == repo_redirect_url + + +@pytest.mark.parametrize("svn_loader_cls", [SvnLoader, SvnLoaderFromRemoteDump]) +def test_loader_basic_authentication_required( + swh_storage, repo_url, tmp_path, svn_loader_cls, svnserve +): + + # add file to empty test repo + add_commit( + repo_url, + "Add project in repository", + [ + CommitChange( + change_type=CommitChangeType.AddOrUpdate, + path="project/foo.sh", + data=b"#!/bin/bash\necho foo", + ), + ], + ) + + # compute repo URLs that will be made available by svnserve + repo_path = repo_url.replace("file://", "") + repo_root = os.path.dirname(repo_path) + repo_name = os.path.basename(repo_path) + username = "anonymous" + password = "anonymous" + port = 12000 + repo_url_no_auth = f"svn://localhost:{port}/{repo_name}" + repo_url = f"svn://{username}:{password}@localhost:{port}/{repo_name}" + + # disable anonymous access and require authentication on test repo + with open(os.path.join(repo_path, "conf", "svnserve.conf"), "w") as svnserve_conf: + svnserve_conf.write( + textwrap.dedent( + """ + [general] + + # Authentication realm of the repository. + realm = test-repository + password-db = passwd + + # Deny all anonymous access + anon-access = none + + # Grant authenticated users read and write privileges + auth-access = write + """ + ) + ) + + # add a user with read/write access on test repo + with open(os.path.join(repo_path, "conf", "passwd"), "w") as passwd: + passwd.write(f"[users]\n{username} = {password}") + + # execute svnserve + svnserve(repo_root, port) + + # check loading failed with no authentication + loader = svn_loader_cls(swh_storage, repo_url_no_auth, temp_directory=tmp_path) + assert loader.load() == {"status": "uneventful"} + + # check loading succeeded with authentication + loader = svn_loader_cls(swh_storage, repo_url, temp_directory=tmp_path) + assert loader.load() == {"status": "eventful"} + assert_last_visit_matches( + loader.storage, + repo_url, + status="full", + type="svn", + ) + + check_snapshot(loader.snapshot, loader.storage)