From 45780256765c000aa9034d1f780bef1ac09223ec Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 2 Jun 2026 20:01:35 +0000 Subject: [PATCH 1/4] Cleanup commands --- .claude/settings.json | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 5a2fb2a8..cb6980dc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,33 +2,33 @@ "permissions": { "allow": [ "Bash(codespell)", - "Bash(python -m behave *)", - "Bash(python -m pytest tests/*)", - "Bash(pre-commit run:*)", - "Bash(git stash:*)", + "Bash(pre-commit run *)", + "Bash(git stash *)", "Bash(xenon *)", "Bash(radon *)", - "Bash(isort --diff dfetch)", - "Bash(black --check dfetch)", - "Bash(pylint dfetch:*)", - "Bash(ruff check:*)", - "Bash(mypy dfetch:*)", - "Bash(pip show:*)", - "Bash(doc8 doc:*)", - "Bash(pydocstyle dfetch:*)", - "Bash(bandit -r dfetch)", + "Bash(isort --diff dfetch*)", + "Bash(black --check dfetch*)", + "Bash(pylint dfetch*)", + "Bash(ruff check dfetch*)", + "Bash(mypy dfetch*)", + "Bash(python -m mypy dfetch*)", + "Bash(python -m pytest tests/*)", + "Bash(pip show *)", + "Bash(doc8 doc*)", + "Bash(pydocstyle dfetch*)", + "Bash(bandit *)", "Bash(pyroma --directory --min=10 .)", - "Bash(xargs pyupgrade:*)", + "Bash(xargs pyupgrade *)", "Bash(lint-imports)", - "Bash(pip install:*)", - "Bash(pytest tests/test_sbom_reporter.py -q)", + "Bash(pip install *)", + "Bash(pytest *)", "Bash(make -C doc latexpdf)", "Bash(make -C doc clean)", - "Bash(dfetch add:*)", - "Bash(dfetch update:*)", - "Bash(python -m security.tm_supply_chain:*)", - "Bash(python -m security.tm_usage:*)", - "Bash(make -C doc html)" + "Bash(make -C doc html)", + "Bash(dfetch add *)", + "Bash(dfetch update *)", + "Bash(python -m security.tm_supply_chain *)", + "Bash(python -m security.tm_usage *)" ], "additionalDirectories": [ "/workspaces/dfetch/.claude" From b50c16c78b25ec2d297f6109314d039bb1a9c5ac Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 2 Jun 2026 20:47:12 +0000 Subject: [PATCH 2/4] Unshallow docs --- .readthedocs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.readthedocs.yml b/.readthedocs.yml index 8aed9d99..16db5010 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -17,6 +17,9 @@ build: os: ubuntu-22.04 tools: python: "3.13" + jobs: + post_checkout: + - git fetch --unshallow || true apt_packages: - texlive-latex-recommended - texlive-fonts-recommended From a6a7617228d0af2fddf88b5265895166713dc69c Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Mon, 25 May 2026 18:12:20 +0200 Subject: [PATCH 3/4] Prevent hanging in svn+ssh --- CHANGELOG.rst | 1 + dfetch/vcs/svn.py | 228 +++++++++++++++----------------- doc/howto/troubleshooting.rst | 36 +++++ features/check-svn-repo.feature | 2 +- tests/test_svn.py | 168 ++++++++++++++++++++++- tests/test_svn_vcs.py | 4 +- 6 files changed, 314 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cef6fe89..dc32139a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -23,6 +23,7 @@ Release 0.14.0 (unreleased) * Allow manifests with no ``projects`` key so ``dfetch add`` can bootstrap empty manifest (#1197) * Fix ``ValueError`` when generating a PackageURL (e.g. for an SBOM) from an empty or path-only remote URL * Fix SSH shorthand URLs (``git@host:path``) being incorrectly joined with ``/`` when used as ``url-base`` with ``repo-path`` (#1247) +* Run ``svn+ssh://`` connections in non-interactive mode to prevent hanging (#1230) Release 0.13.0 (released 2026-03-30) ==================================== diff --git a/dfetch/vcs/svn.py b/dfetch/vcs/svn.py index e6c44d1c..155cd873 100644 --- a/dfetch/vcs/svn.py +++ b/dfetch/vcs/svn.py @@ -1,12 +1,15 @@ """Svn repository.""" import contextlib +import functools import os import pathlib import re -from collections.abc import Callable, Generator, Sequence +from collections.abc import Callable, Generator, Mapping, Sequence from pathlib import Path +from types import MappingProxyType from typing import NamedTuple +from urllib.parse import urlparse from dfetch.log import get_logger from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline @@ -15,11 +18,77 @@ logger = get_logger(__name__) +_SSH_HOST_KEY_MSGS = ("host key verification failed", "authenticity of host") + + +class SshHostKeyError(RuntimeError): + """Raised when SVN cannot connect due to an untrusted SSH host key.""" + + +# As a cli tool, we can safely assume this remains stable during the runtime, caching for speed is better +@functools.lru_cache +def _extend_env_for_non_interactive_mode() -> Mapping[str, str]: + """Extend the environment vars for svn running in non-interactive mode.""" + env = os.environ.copy() + ssh_cmd = env.get("SVN_SSH", "ssh") + if "BatchMode=" not in ssh_cmd: + ssh_cmd += " -o BatchMode=yes" + else: + logger.debug('BatchMode already configured in SVN_SSH: "%s"', ssh_cmd) + env["SVN_SSH"] = ssh_cmd + return MappingProxyType(env) + + +def _raise_if_ssh_host_key_error(url: str, exc: SubprocessCommandError) -> None: + """Raise a helpful SshHostKeyError if *exc* looks like an SSH host-key failure.""" + stderr_lower = exc.stderr.lower() + if not any(msg in stderr_lower for msg in _SSH_HOST_KEY_MSGS): + return + parsed = urlparse(url) + if parsed.hostname: + host = parsed.hostname + target = f"{parsed.username}@{host}" if parsed.username else host + raise SshHostKeyError( + f"SSH host key verification failed while connecting to '{url}'.\n" + "Add the host to your known hosts file, for example by running:\n" + f" ssh-keyscan {host} >> ~/.ssh/known_hosts\n" + "Or test the SSH connection manually:\n" + f" ssh -T {target}" + ) from exc + raise SshHostKeyError( + "SSH host key verification failed while connecting to the repository.\n" + "Add the repository's host to your known hosts file, for example by running:\n" + " ssh-keyscan >> ~/.ssh/known_hosts" + ) from exc + + +def _run_svn_raw(args: list[str], *, url: str = "") -> bytes: + """Run an svn subcommand and return raw stdout bytes. + + Uses --non-interactive and the non-interactive SSH env on every call. + SSH host-key failures are converted to SshHostKeyError so callers don't + need to handle that case individually. + """ + try: + result = run_on_cmdline( + logger, + ["svn", "--non-interactive"] + args, + env=_extend_env_for_non_interactive_mode(), + ) + return bytes(result.stdout) + except SubprocessCommandError as exc: + _raise_if_ssh_host_key_error(url, exc) + raise + + +def _run_svn(args: list[str], *, url: str = "") -> str: + """Run an svn subcommand and return decoded stdout (see _run_svn_raw).""" + return _run_svn_raw(args, url=url).decode() + def get_svn_version() -> tuple[str, str]: """Get the name and version of svn.""" - result = run_on_cmdline(logger, ["svn", "--version", "--non-interactive"]) - first_line = result.stdout.decode().split("\n")[0] + first_line = _run_svn(["--version"]).split("\n", maxsplit=1)[0] if "version" not in first_line.lower(): raise RuntimeError(f"Unexpected svn --version output format: {first_line}") tool, version = first_line.replace(",", "").split("version", maxsplit=1) @@ -49,8 +118,10 @@ def __init__(self, remote: str) -> None: def is_svn(self) -> bool: """Check if is SVN.""" try: - run_on_cmdline(logger, ["svn", "info", self._remote, "--non-interactive"]) + _run_svn(["info", self._remote], url=self._remote) return True + except SshHostKeyError: + raise except SubprocessCommandError as exc: if exc.stderr.startswith("svn: E170013"): raise RuntimeError( @@ -64,26 +135,19 @@ def is_svn(self) -> bool: def list_of_branches(self) -> list[str]: """List branch names from the ``branches/`` directory.""" try: - result = run_on_cmdline( - logger, - ["svn", "ls", "--non-interactive", f"{self._remote}/branches"], - ) + output = _run_svn(["ls", f"{self._remote}/branches"], url=self._remote) return [ - line.strip("/\r") - for line in result.stdout.decode().splitlines() - if line.strip("/\r") + line.strip("/\r") for line in output.splitlines() if line.strip("/\r") ] + except SshHostKeyError: + raise except (SubprocessCommandError, RuntimeError): return [] def list_of_tags(self) -> list[str]: """Get list of all available tags.""" - result = run_on_cmdline( - logger, ["svn", "ls", "--non-interactive", f"{self._remote}/tags"] - ) - return [ - str(tag).strip("/\r") for tag in result.stdout.decode().split("\n") if tag - ] + output = _run_svn(["ls", f"{self._remote}/tags"], url=self._remote) + return [str(tag).strip("/\r") for tag in output.split("\n") if tag] @contextlib.contextmanager def browse_tree( @@ -103,6 +167,8 @@ def browse_tree( try: SvnRepo.get_info_from_target(branches_url) base_url = branches_url + except SshHostKeyError: + raise except RuntimeError: base_url = f"{self._remote}/tags/{version}" @@ -115,17 +181,17 @@ def ls(path: str = "") -> list[tuple[str, bool]]: def ls_tree(self, url_path: str) -> list[tuple[str, bool]]: """List immediate children of *url_path* as ``(name, is_dir)`` pairs.""" try: - result = run_on_cmdline( - logger, ["svn", "ls", "--non-interactive", url_path] - ) + output = _run_svn(["ls", url_path], url=url_path) entries: list[tuple[str, bool]] = [] - for line in result.stdout.decode().splitlines(): + for line in output.splitlines(): line = line.strip("\r") if not line: continue is_dir = line.endswith("/") entries.append((line.rstrip("/"), is_dir)) return entries + except SshHostKeyError: + raise except (SubprocessCommandError, RuntimeError): return [] @@ -146,7 +212,7 @@ def is_svn(self) -> bool: """Check if is SVN.""" try: with in_directory(self._path): - run_on_cmdline(logger, ["svn", "info", "--non-interactive"]) + _run_svn(["info"]) return True except (SubprocessCommandError, RuntimeError): return False @@ -154,31 +220,17 @@ def is_svn(self) -> bool: def externals(self) -> list[External]: """Get list of externals.""" with in_directory(self._path): - result = run_on_cmdline( - logger, - [ - "svn", - "--non-interactive", - "propget", - "svn:externals", - "-R", - ], - ) + output = _run_svn(["propget", "svn:externals", "-R"]) repo_root = SvnRepo.get_info_from_target()["Repository Root"] - return SvnRepo._parse_externals( - result.stdout.decode(), repo_root, toplevel=self._path - ) + return SvnRepo._parse_externals(output, repo_root, toplevel=self._path) @staticmethod def externals_from_url(url: str, revision: str = "") -> list[External]: """Get list of externals from a remote SVN URL.""" - cmd = ["svn", "--non-interactive", "propget", "svn:externals", "-R"] - if revision: - cmd += ["--revision", revision] - cmd += [url] - result = run_on_cmdline(logger, cmd) + extra = ["--revision", revision] if revision else [] + output = _run_svn(["propget", "svn:externals", "-R"] + extra + [url], url=url) repo_root = SvnRepo.get_info_from_target(url)["Repository Root"] - normalized = SvnRepo._normalize_url_prefix(result.stdout.decode(), url) + normalized = SvnRepo._normalize_url_prefix(output, url) return SvnRepo._parse_externals(normalized, repo_root) @staticmethod @@ -291,9 +343,7 @@ def _split_url(url: str, repo_root: str) -> tuple[str, str, str, str]: def get_info_from_target(target: str = "") -> dict[str, str]: """Get the info of the given target.""" try: - result = run_on_cmdline( - logger, ["svn", "info", "--non-interactive", target.strip()] - ).stdout.decode() + output = _run_svn(["info", target.strip()], url=target) except SubprocessCommandError as exc: if exc.stderr.startswith("svn: E170013"): raise RuntimeError( @@ -306,7 +356,7 @@ def get_info_from_target(target: str = "") -> dict[str, str]: key.strip(): value.strip() for key, value in ( line.split(":", maxsplit=1) - for line in result.split(os.linesep) + for line in output.split(os.linesep) if line and ":" in line ) } @@ -324,36 +374,16 @@ def get_last_changed_revision(target: str | Path) -> str: return parsed_version.group("digits") raise RuntimeError(f"svnversion output was unexpected: {version}") - return str( - run_on_cmdline( - logger, - [ - "svn", - "info", - "--non-interactive", - "--show-item", - "last-changed-revision", - target_str, - ], - ) - .stdout.decode() - .strip() - ) + return _run_svn( + ["info", "--show-item", "last-changed-revision", target_str], + url=target_str, + ).strip() @staticmethod def untracked_files(path: str, ignore: Sequence[str]) -> list[str]: """Get list of untracked files in the working copy.""" - result = ( - run_on_cmdline( - logger, - ["svn", "status", "--non-interactive", path], - ) - .stdout.decode() - .splitlines() - ) - files = [] - for line in result: + for line in _run_svn(["status", path]).splitlines(): if line.startswith("?"): file_path = line[1:].strip() if not any( @@ -377,24 +407,15 @@ def export(url: str, rev: str = "", dst: str = ".") -> None: """ if rev and not rev.isdigit(): raise ValueError(f"SVN revision must be digits only, got: {rev!r}") - run_on_cmdline( - logger, - ["svn", "export", "--non-interactive", "--force"] - + (["--revision", rev] if rev else []) - + [url, dst], + _run_svn( + ["export", "--force"] + (["--revision", rev] if rev else []) + [url, dst], + url=url, ) @staticmethod def files_in_path(url_path: str) -> list[str]: """List all files in path at the given url.""" - return [ - str(line) - for line in run_on_cmdline( - logger, ["svn", "list", "--non-interactive", url_path] - ) - .stdout.decode() - .splitlines() - ] + return _run_svn(["list", url_path], url=url_path).splitlines() @staticmethod def ignored_files(path: str) -> Sequence[str]: @@ -403,16 +424,9 @@ def ignored_files(path: str) -> Sequence[str]: return [] with in_directory(path): - result = ( - run_on_cmdline( - logger, - ["svn", "status", "--non-interactive", "--no-ignore", "."], - ) - .stdout.decode() - .splitlines() - ) + lines = _run_svn(["status", "--no-ignore", "."]).splitlines() - return [line[1:].strip() for line in result if line.startswith("I")] + return [line[1:].strip() for line in lines if line.startswith("I")] @staticmethod def any_changes_or_untracked(path: str) -> bool: @@ -421,18 +435,7 @@ def any_changes_or_untracked(path: str) -> bool: raise RuntimeError("Path does not exist.") with in_directory(path): - return bool( - run_on_cmdline( - logger, - [ - "svn", - "status", - ".", - ], - ) - .stdout.decode() - .splitlines() - ) + return bool(_run_svn(["status", "."]).splitlines()) def create_diff( self, @@ -441,7 +444,7 @@ def create_diff( ignore: Sequence[str], ) -> Patch: """Generate a relative diff patch.""" - cmd = ["svn", "diff", "--non-interactive", "--ignore-properties", "."] + cmd = ["diff", "--ignore-properties", "."] if old_revision: cmd.extend( @@ -452,7 +455,7 @@ def create_diff( ) with in_directory(self._path): - patch_text = run_on_cmdline(logger, cmd).stdout + patch_text = _run_svn_raw(cmd) if not patch_text.strip(): return Patch.empty().convert_type(PatchType.SVN) @@ -461,17 +464,6 @@ def create_diff( def get_username(self) -> str: """Get the username of the local svn repo.""" try: - result = run_on_cmdline( - logger, - [ - "svn", - "info", - "--non-interactive", - "--show-item", - "author", - self._path, - ], - ) - return str(result.stdout.decode().strip()) + return _run_svn(["info", "--show-item", "author", self._path]).strip() except SubprocessCommandError: return "" diff --git a/doc/howto/troubleshooting.rst b/doc/howto/troubleshooting.rst index 657bee5a..593405ce 100644 --- a/doc/howto/troubleshooting.rst +++ b/doc/howto/troubleshooting.rst @@ -56,6 +56,42 @@ You can report issues via: .. _`Gitter`: https://gitter.im/dfetch-org/community +Using remotes over SSH +---------------------- + +*Dfetch* runs all ``git`` and ``svn`` commands in non-interactive mode, so it works +reliably in scripts and CI without hanging on a prompt. For SSH remotes (such as +``git@github.com:...`` or ``svn+ssh://...``) this means SSH runs with +``BatchMode=yes``: authentication happens without typing a password and the host key +needs to be trusted beforehand. You can prepare your environment once: + +1. Trust the host key, for example: + + .. code-block:: bash + + $ ssh-keyscan svn.example.com >> ~/.ssh/known_hosts + +2. Use key-based authentication, for example by loading your key into the + ``ssh-agent``: + + .. code-block:: bash + + $ eval "$(ssh-agent)" && ssh-add ~/.ssh/my_key + + or by configuring the key per host in ``~/.ssh/config``. + +If you need specific SSH options, you can set the ``GIT_SSH_COMMAND`` (git also honors +``core.sshCommand``) or ``SVN_SSH`` environment variables; *Dfetch* respects them and +only adds ``BatchMode=yes`` when you haven't configured ``BatchMode`` yourself: + +.. code-block:: bash + + $ export GIT_SSH_COMMAND="ssh -i ~/.ssh/my_key" + $ export SVN_SSH="ssh -i ~/.ssh/my_key" + +After this, SSH projects fetch just like any other remote. + + Security issues ---------------- diff --git a/features/check-svn-repo.feature b/features/check-svn-repo.feature index c9624e71..e6b588f1 100644 --- a/features/check-svn-repo.feature +++ b/features/check-svn-repo.feature @@ -138,7 +138,7 @@ Feature: Checking dependencies from a svn repository Then the output shows """ Dfetch (0.13.0) - >>>svn info --non-interactive https://giiiiiidhub.com/i-do-not-exist/broken/trunk<<< failed! + >>>svn --non-interactive info https://giiiiiidhub.com/i-do-not-exist/broken/trunk<<< failed! 'https://giiiiiidhub.com/i-do-not-exist/broken/trunk' is not a valid URL or unreachable: svn: E170013: Unable to connect to a repository at URL 'https://giiiiiidhub.com/i-do-not-exist/broken/trunk' svn: E670002: Name or service not known diff --git a/tests/test_svn.py b/tests/test_svn.py index 30190a7a..e2fbac40 100644 --- a/tests/test_svn.py +++ b/tests/test_svn.py @@ -4,14 +4,14 @@ # flake8: noqa import os -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from dfetch.manifest.project import ProjectEntry from dfetch.project.svnsubproject import SvnSubProject from dfetch.util.cmdline import SubprocessCommandError -from dfetch.vcs.svn import External, SvnRemote, SvnRepo +from dfetch.vcs.svn import External, SshHostKeyError, SvnRemote, SvnRepo REPO_ROOT = "repo-root" CWD = "C:\\mydir" @@ -251,7 +251,7 @@ def test_externals(name, externals, expectations): @pytest.mark.parametrize( "name, cmd_result, expectation", [ - ("svn repo", ["Yep!"], True), + ("svn repo", [MagicMock(stdout=b"")], True), ("not a svn repo", [SubprocessCommandError([""], "", "", -1)], False), ("no svn", [RuntimeError()], False), ], @@ -266,7 +266,7 @@ def test_check_path(name, cmd_result, expectation): @pytest.mark.parametrize( "name, cmd_result, expectation", [ - ("Ok url", ["Yep!"], True), + ("Ok url", [MagicMock(stdout=b"")], True), ( "Failed command", [SubprocessCommandError], @@ -520,3 +520,163 @@ def test_externals_from_url_nonstd_layout_branch_is_space(): assert result[0].url == nonstd_url assert result[0].revision == "" assert result[0].path == "Database" + + +@pytest.mark.parametrize( + "method,url,call_args", + [ + ("is_svn", "svn+ssh://svn.code.sf.net/project", ()), + ("list_of_tags", "svn+ssh://svn.code.sf.net/project", ()), + ("list_of_branches", "svn+ssh://svn.code.sf.net/project", ()), + ( + "ls_tree", + "svn+ssh://svn.code.sf.net/project", + ("svn+ssh://svn.code.sf.net/project",), + ), + ], +) +def test_svn_remote_raises_hint_on_ssh_host_key_failure(method, url, call_args): + """Test that SvnRemote methods raise SshHostKeyError with a hint on host-key failure.""" + stderr = "Host key verification failed." + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1) + + with pytest.raises(SshHostKeyError, match="known hosts"): + getattr(SvnRemote(url), method)(*call_args) + + +def test_get_info_from_target_raises_hint_on_ssh_host_key_failure(): + """Test that get_info_from_target raises SshHostKeyError instead of a generic error.""" + stderr = "Host key verification failed." + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.side_effect = SubprocessCommandError(["svn", "info"], "", stderr, 1) + + with pytest.raises(SshHostKeyError, match="known hosts"): + SvnRepo.get_info_from_target("svn+ssh://svn.code.sf.net/project") + + +@pytest.mark.parametrize( + "method,url", + [ + ("externals_from_url", "svn+ssh://svn.code.sf.net/project"), + ("get_last_changed_revision", "svn+ssh://svn.code.sf.net/project"), + ("export", "svn+ssh://svn.code.sf.net/project"), + ("files_in_path", "svn+ssh://svn.code.sf.net/project"), + ], +) +def test_svn_repo_raises_hint_on_ssh_host_key_failure(method, url): + """Test that static SvnRepo methods raise SshHostKeyError on host-key failure.""" + stderr = "Host key verification failed." + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1) + + with pytest.raises(SshHostKeyError, match="known hosts"): + getattr(SvnRepo, method)(url) + + +def test_ssh_hint_includes_hostname(): + """Test that the host-key hint contains the hostname parsed from the URL.""" + stderr = "Host key verification failed." + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1) + + with pytest.raises(SshHostKeyError, match="svn.code.sf.net"): + SvnRemote("svn+ssh://svn.code.sf.net/project").is_svn() + + +def test_ssh_hint_includes_user_when_present_in_url(): + """Test that the host-key hint suggests ssh with the user from the URL.""" + stderr = "Host key verification failed." + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1) + + with pytest.raises(SshHostKeyError, match="myuser@svn.code.sf.net"): + SvnRemote("svn+ssh://myuser@svn.code.sf.net/project").is_svn() + + +def test_svn_ssh_env_has_batch_mode(): + """Test that the svn environment forces SSH BatchMode by default.""" + from dfetch.vcs.svn import _extend_env_for_non_interactive_mode + + _extend_env_for_non_interactive_mode.cache_clear() + env = _extend_env_for_non_interactive_mode() + assert "BatchMode=yes" in env["SVN_SSH"] + + +def test_svn_ssh_env_preserves_existing_batch_mode(monkeypatch): + """Test that a user-configured BatchMode in SVN_SSH is left untouched.""" + from dfetch.vcs.svn import _extend_env_for_non_interactive_mode + + monkeypatch.setenv("SVN_SSH", "ssh -o BatchMode=yes -i /my/key") + _extend_env_for_non_interactive_mode.cache_clear() + env = _extend_env_for_non_interactive_mode() + assert env["SVN_SSH"].count("BatchMode=yes") == 1 + assert "-i /my/key" in env["SVN_SSH"] + + +def test_run_svn_passes_non_interactive_env_to_subprocess(): + """Test that svn commands receive the non-interactive SSH environment.""" + from dfetch.vcs.svn import _extend_env_for_non_interactive_mode + + _extend_env_for_non_interactive_mode.cache_clear() + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.return_value = MagicMock(stdout=b"") + + SvnRepo.files_in_path("svn+ssh://svn.code.sf.net/project") + + env = mock_run.call_args.kwargs["env"] + assert "BatchMode=yes" in env["SVN_SSH"] + + +def test_ssh_hint_on_authenticity_of_host_message(): + """Test that the 'authenticity of host' stderr variant also triggers the hint.""" + stderr = "The authenticity of host 'svn.code.sf.net' can't be established." + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1) + + with pytest.raises(SshHostKeyError, match="known hosts"): + SvnRemote("svn+ssh://svn.code.sf.net/project").is_svn() + + +def test_ssh_hint_without_url_omits_hostname_commands(): + """Test that the hint uses a placeholder when no hostname can be parsed.""" + stderr = "Host key verification failed." + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1) + + with pytest.raises(SshHostKeyError, match="known hosts") as exc_info: + SvnRepo(".").create_diff("1", "2", []) + + assert "ssh-keyscan " in str(exc_info.value) + + +def test_browse_tree_raises_hint_on_ssh_host_key_failure(): + """Test that browse_tree surfaces SshHostKeyError instead of falling back to tags.""" + stderr = "Host key verification failed." + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1) + + with pytest.raises(SshHostKeyError, match="known hosts"): + with SvnRemote("svn+ssh://svn.code.sf.net/project").browse_tree( + "some-branch" + ): + pass + + +def test_create_diff_handles_non_utf8_diff_output(): + """Test that create_diff handles svn diff output that is not valid UTF-8.""" + diff = ( + b"Index: a.c\n" + b"===================================================================\n" + b"--- a.c\t(revision 1)\n" + b"+++ a.c\t(working copy)\n" + b"@@ -1 +1 @@\n" + b"-old text \xe9\n" + b"+new text \xe9\n" + ) + with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run: + mock_run.return_value = MagicMock(stdout=diff) + + patch_obj = SvnRepo(".").create_diff("1", "2", []) + + assert not patch_obj.is_empty() diff --git a/tests/test_svn_vcs.py b/tests/test_svn_vcs.py index 313d27ec..39960176 100644 --- a/tests/test_svn_vcs.py +++ b/tests/test_svn_vcs.py @@ -14,8 +14,8 @@ def test_export_with_revision_passes_correct_args(): mock_run.assert_called_once() assert mock_run.call_args[0][1] == [ "svn", - "export", "--non-interactive", + "export", "--force", "--revision", "12345", @@ -31,8 +31,8 @@ def test_export_without_revision_omits_revision_args(): mock_run.assert_called_once() assert mock_run.call_args[0][1] == [ "svn", - "export", "--non-interactive", + "export", "--force", "svn://example.com/repo", "/tmp/out", From 5fd202615e30e587a3113f6d57c2856a5906de63 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 10 Jun 2026 20:50:22 +0000 Subject: [PATCH 4/4] fixup --- .readthedocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 16db5010..56b9b4d4 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -19,7 +19,7 @@ build: python: "3.13" jobs: post_checkout: - - git fetch --unshallow || true + - if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then git fetch --unshallow; fi apt_packages: - texlive-latex-recommended - texlive-fonts-recommended