diff --git a/src/scmrepo/git/backend/dulwich/__init__.py b/src/scmrepo/git/backend/dulwich/__init__.py index 7c2772e7..3a6b45dc 100644 --- a/src/scmrepo/git/backend/dulwich/__init__.py +++ b/src/scmrepo/git/backend/dulwich/__init__.py @@ -98,14 +98,32 @@ def write(self, msg: Union[str, bytes]) -> int: def _get_ssh_vendor() -> "SSHVendor": + import sys + from dulwich.client import SubprocessSSHVendor - from .asyncssh_vendor import AsyncSSHVendor + from .asyncssh_vendor import AsyncSSHVendor, get_unsupported_opts ssh_command = os.environ.get("GIT_SSH_COMMAND", os.environ.get("GIT_SSH")) if ssh_command: logger.debug("dulwich: Using environment GIT_SSH_COMMAND '%s'", ssh_command) return SubprocessSSHVendor() + + if sys.platform == "win32" and os.environ.get("MSYSTEM"): + # see https://github.com/iterative/dvc/issues/7702 + logger.debug( + "dulwich: native win32 Python inside MSYS2/git-bash, using MSYS2 OpenSSH" + ) + return SubprocessSSHVendor() + + default_config = os.path.expanduser(os.path.join("~", ".ssh", "config")) + unsupported = list(get_unsupported_opts([default_config])) + if unsupported: + logger.debug( + "dulwich: unsupported SSH config option(s) '%s', using system OpenSSH", + ", ".join(unsupported), + ) + return SubprocessSSHVendor() return AsyncSSHVendor() diff --git a/src/scmrepo/git/backend/dulwich/asyncssh_vendor.py b/src/scmrepo/git/backend/dulwich/asyncssh_vendor.py index 6da38bde..cbe131ff 100644 --- a/src/scmrepo/git/backend/dulwich/asyncssh_vendor.py +++ b/src/scmrepo/git/backend/dulwich/asyncssh_vendor.py @@ -1,6 +1,14 @@ """asyncssh SSH vendor for Dulwich.""" import asyncio -from typing import TYPE_CHECKING, Callable, Coroutine, List, Optional +from typing import ( + TYPE_CHECKING, + Callable, + Coroutine, + Iterator, + List, + Optional, + Sequence, +) from dulwich.client import SSHVendor @@ -8,6 +16,9 @@ from scmrepo.exceptions import AuthError if TYPE_CHECKING: + from pathlib import Path + + from asyncssh.config import ConfigPaths, FilePath from asyncssh.connection import SSHClientConnection from asyncssh.process import SSHClientProcess from asyncssh.stream import SSHReader @@ -172,3 +183,51 @@ async def _run_command( return AsyncSSHWrapper(conn, proc) run_command = sync_wrapper(_run_command) + + +# class ValidatedSSHClientConfig(SSHClientConfig): +# pass + + +def get_unsupported_opts(config_paths: "ConfigPaths") -> Iterator[str]: + from pathlib import Path, PurePath + + if config_paths: + if isinstance(config_paths, (str, PurePath)): + paths: Sequence["FilePath"] = [config_paths] + else: + paths = config_paths + + for path in paths: + try: + yield from _parse_unsupported(Path(path)) + except FileNotFoundError: + continue + + +def _parse_unsupported(path: "Path") -> Iterator[str]: + import locale + import shlex + + from asyncssh.config import SSHClientConfig + + handlers = SSHClientConfig._handlers # pylint: disable=protected-access + with open(path, encoding=locale.getpreferredencoding()) as fobj: + for line in fobj: + line = line.strip() + if not line or line[0] == "#": + continue + + try: + args = shlex.split(line) + except ValueError: + continue + + option = args.pop(0) + if option.endswith("="): + option = option[:-1] + elif "=" in option: + option, _ = option.split("=", 1) + loption = option.lower() + if loption not in handlers: + yield loption diff --git a/tests/test_dulwich.py b/tests/test_dulwich.py index ccc459c8..ed100b74 100644 --- a/tests/test_dulwich.py +++ b/tests/test_dulwich.py @@ -1,3 +1,4 @@ +import os import socket import threading from io import StringIO @@ -157,3 +158,43 @@ def test_dulwich_github_compat(mocker: MockerFixture, algorithm: bytes): strings = iter((b"ssh-rsa", key_data)) packet.get_string = lambda: next(strings) _process_public_key_ok_gh(auth, None, None, packet) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows only") +def test_git_bash_ssh_vendor(mocker): + from dulwich.client import SubprocessSSHVendor + + from scmrepo.git.backend.dulwich import _get_ssh_vendor + + mocker.patch.dict(os.environ, {"MSYSTEM": "MINGW64"}) + assert isinstance(_get_ssh_vendor(), SubprocessSSHVendor) + + del os.environ["MSYSTEM"] + assert isinstance(_get_ssh_vendor(), AsyncSSHVendor) + + +def test_unsupported_config_ssh_vendor(): + from dulwich.client import SubprocessSSHVendor + + from scmrepo.git.backend.dulwich import _get_ssh_vendor + + config = os.path.expanduser(os.path.join("~", ".ssh", "config")) + os.makedirs(os.path.dirname(config), exist_ok=True) + + with open(config, "wb") as fobj: + fobj.write( + b""" +Host * + IdentityFile ~/.ssh/id_rsa +""" + ) + assert isinstance(_get_ssh_vendor(), AsyncSSHVendor) + + with open(config, "wb") as fobj: + fobj.write( + b""" +Host * + UseKeychain yes +""" + ) + assert isinstance(_get_ssh_vendor(), SubprocessSSHVendor)